Skip to main content

yaml_rt_core/
patch.rs

1use std::collections::HashSet;
2use std::fmt;
3
4use crate::{
5    JsonPointer, NodeId, ResolvedScalar, SemanticKind, Span, YamlDoc, YamlFragment, resolve_scalar,
6};
7
8/// A parsed sequence of RFC 6902-style operations over YAML values.
9#[derive(Debug, Clone)]
10pub struct YamlPatch {
11    operations: Vec<YamlPatchOperation>,
12    operation_spans: Vec<Option<Span>>,
13}
14
15impl YamlPatch {
16    /// Creates a patch from programmatically constructed operations.
17    #[must_use]
18    pub fn new(operations: Vec<YamlPatchOperation>) -> Self {
19        let operation_spans = vec![None; operations.len()];
20        Self {
21            operations,
22            operation_spans,
23        }
24    }
25
26    /// Parses a YAML or JSON patch document.
27    ///
28    /// # Errors
29    ///
30    /// Returns an error when the source is invalid YAML or does not have the
31    /// required sequence-of-operation-mappings shape.
32    pub fn parse(input: &str) -> Result<Self, YamlPatchError> {
33        Self::parse_owned(input.to_owned())
34    }
35
36    /// Parses an owned YAML or JSON patch document without first copying it.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error under the same conditions as [`Self::parse`].
41    pub fn parse_owned(input: String) -> Result<Self, YamlPatchError> {
42        let doc = YamlDoc::parse_owned(input).map_err(|error| {
43            YamlPatchError::new(
44                YamlPatchErrorKind::Syntax,
45                None,
46                Some(error.diagnostic.span),
47                error.to_string(),
48            )
49        })?;
50        if doc.document_count() != 1 {
51            return Err(YamlPatchError::structure(
52                None,
53                None,
54                format!(
55                    "a YAML patch must contain exactly one document, found {}",
56                    doc.document_count()
57                ),
58            ));
59        }
60        let root = doc
61            .document_root(0)
62            .map_err(|error| YamlPatchError::structure(None, None, error.to_string()))?
63            .ok_or_else(|| {
64                YamlPatchError::structure(None, None, "a YAML patch must have a sequence root")
65            })?;
66        require_undecorated_collection(&doc, root, SemanticCollection::Sequence, None)?;
67
68        let mut operations = Vec::new();
69        let mut operation_spans = Vec::new();
70        for (index, node) in doc.sequence_items(root).enumerate() {
71            let span = doc.node(node).map(|node| node.span());
72            require_undecorated_collection(&doc, node, SemanticCollection::Mapping, Some(index))?;
73            operations.push(parse_operation(&doc, node, index)?);
74            operation_spans.push(span);
75        }
76        Ok(Self {
77            operations,
78            operation_spans,
79        })
80    }
81
82    /// Returns the operations in application order.
83    #[must_use]
84    pub fn operations(&self) -> &[YamlPatchOperation] {
85        &self.operations
86    }
87
88    /// Consumes this patch and returns its operations.
89    #[must_use]
90    pub fn into_operations(self) -> Vec<YamlPatchOperation> {
91        self.operations
92    }
93
94    fn operation_span(&self, index: usize) -> Option<Span> {
95        self.operation_spans.get(index).copied().flatten()
96    }
97}
98
99impl PartialEq for YamlPatch {
100    fn eq(&self, other: &Self) -> bool {
101        self.operations == other.operations
102    }
103}
104
105impl Eq for YamlPatch {}
106
107/// One YAML patch operation.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum YamlPatchOperation {
110    /// Adds a value, replacing an existing mapping member when present.
111    Add {
112        /// Destination JSON Pointer.
113        path: JsonPointer,
114        /// Value to add.
115        value: YamlFragment,
116    },
117    /// Removes an existing value.
118    Remove {
119        /// Target JSON Pointer.
120        path: JsonPointer,
121    },
122    /// Replaces an existing value.
123    Replace {
124        /// Target JSON Pointer.
125        path: JsonPointer,
126        /// Replacement value.
127        value: YamlFragment,
128    },
129    /// Moves an existing value.
130    Move {
131        /// Source JSON Pointer.
132        from: JsonPointer,
133        /// Destination JSON Pointer.
134        path: JsonPointer,
135    },
136    /// Copies an existing value.
137    Copy {
138        /// Source JSON Pointer.
139        from: JsonPointer,
140        /// Destination JSON Pointer.
141        path: JsonPointer,
142    },
143    /// Tests semantic equality without changing the document.
144    Test {
145        /// Target JSON Pointer.
146        path: JsonPointer,
147        /// Expected value.
148        value: YamlFragment,
149    },
150}
151
152/// Classification of a YAML patch failure.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum YamlPatchErrorKind {
155    /// The patch source is not valid YAML.
156    Syntax,
157    /// The parsed document is not a valid patch document.
158    Structure,
159    /// A valid operation could not be applied to the target document.
160    Application,
161}
162
163/// Failure while parsing or applying a YAML patch.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct YamlPatchError {
166    kind: YamlPatchErrorKind,
167    operation_index: Option<usize>,
168    span: Option<Span>,
169    message: String,
170}
171
172impl YamlPatchError {
173    fn new(
174        kind: YamlPatchErrorKind,
175        operation_index: Option<usize>,
176        span: Option<Span>,
177        message: impl Into<String>,
178    ) -> Self {
179        Self {
180            kind,
181            operation_index,
182            span,
183            message: message.into(),
184        }
185    }
186
187    fn structure(
188        operation_index: Option<usize>,
189        span: Option<Span>,
190        message: impl Into<String>,
191    ) -> Self {
192        Self::new(
193            YamlPatchErrorKind::Structure,
194            operation_index,
195            span,
196            message,
197        )
198    }
199
200    fn application(
201        operation_index: Option<usize>,
202        span: Option<Span>,
203        message: impl Into<String>,
204    ) -> Self {
205        Self::new(
206            YamlPatchErrorKind::Application,
207            operation_index,
208            span,
209            message,
210        )
211    }
212
213    /// Returns the phase in which the patch failed.
214    #[must_use]
215    pub const fn kind(&self) -> YamlPatchErrorKind {
216        self.kind
217    }
218
219    /// Returns the zero-based operation index, when one operation is involved.
220    #[must_use]
221    pub const fn operation_index(&self) -> Option<usize> {
222        self.operation_index
223    }
224
225    /// Returns the relevant patch-source span, when the patch was parsed from text.
226    #[must_use]
227    pub const fn span(&self) -> Option<Span> {
228        self.span
229    }
230}
231
232impl fmt::Display for YamlPatchError {
233    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
234        if let Some(index) = self.operation_index {
235            write!(formatter, "patch operation[{index}]: {}", self.message)
236        } else {
237            write!(formatter, "YAML patch: {}", self.message)
238        }
239    }
240}
241
242impl std::error::Error for YamlPatchError {}
243
244impl YamlDoc {
245    /// Applies every operation in a patch transactionally to one YAML document.
246    ///
247    /// Operations run in sequence and later pointers observe earlier changes.
248    /// The receiver is replaced only after every operation succeeds.
249    ///
250    /// # Errors
251    ///
252    /// Returns an error when the selected document does not exist, an operation
253    /// cannot be applied, or a `test` operation compares unequal.
254    pub fn apply_patch(
255        &mut self,
256        document: usize,
257        patch: &YamlPatch,
258    ) -> Result<(), YamlPatchError> {
259        let mut work = self.clone();
260        work.document_root(document)
261            .map_err(|error| YamlPatchError::application(None, None, error.to_string()))?;
262        for (index, operation) in patch.operations.iter().enumerate() {
263            let result = match operation {
264                YamlPatchOperation::Add { path, value } => work.add_at(document, path, value),
265                YamlPatchOperation::Remove { path } => work.remove_at(document, path),
266                YamlPatchOperation::Replace { path, value } => {
267                    work.replace_at(document, path, value)
268                }
269                YamlPatchOperation::Move { from, path } => work.move_at(document, from, path),
270                YamlPatchOperation::Copy { from, path } => work.copy_at(document, from, path),
271                YamlPatchOperation::Test { path, value } => {
272                    match work.test_at(document, path, value) {
273                        Ok(true) => Ok(()),
274                        Ok(false) => Err(crate::YamlEditError::new(format!(
275                            "test failed at {:?}: values are not semantically equal",
276                            path.as_str()
277                        ))),
278                        Err(error) => Err(error),
279                    }
280                }
281            };
282            if let Err(error) = result {
283                return Err(YamlPatchError::application(
284                    Some(index),
285                    patch.operation_span(index),
286                    error.to_string(),
287                ));
288            }
289        }
290        *self = work;
291        Ok(())
292    }
293}
294
295#[derive(Clone, Copy)]
296enum SemanticCollection {
297    Sequence,
298    Mapping,
299}
300
301fn require_undecorated_collection(
302    doc: &YamlDoc,
303    node: NodeId,
304    expected: SemanticCollection,
305    operation_index: Option<usize>,
306) -> Result<(), YamlPatchError> {
307    let span = doc.node(node).map(|node| node.span());
308    let valid_kind = matches!(
309        (expected, doc.semantic_kind(node)),
310        (
311            SemanticCollection::Sequence,
312            Some(SemanticKind::Sequence { .. })
313        ) | (
314            SemanticCollection::Mapping,
315            Some(SemanticKind::Mapping { .. })
316        )
317    );
318    if !valid_kind {
319        let expected_name = match expected {
320            SemanticCollection::Sequence => "a sequence root",
321            SemanticCollection::Mapping => "a mapping",
322        };
323        let message = if operation_index.is_some() {
324            format!("a YAML patch operation must be {expected_name}")
325        } else {
326            format!("a YAML patch must have {expected_name}")
327        };
328        return Err(YamlPatchError::structure(operation_index, span, message));
329    }
330    if doc.raw_tag(node).is_some() || doc.anchor(node).is_some() {
331        return Err(YamlPatchError::structure(
332            operation_index,
333            span,
334            "patch structural collections cannot have tags or anchors",
335        ));
336    }
337    Ok(())
338}
339
340fn parse_operation(
341    doc: &YamlDoc,
342    mapping: NodeId,
343    index: usize,
344) -> Result<YamlPatchOperation, YamlPatchError> {
345    let mut names = HashSet::new();
346    let mut operation = None;
347    let mut path = None;
348    let mut from = None;
349    let mut value = None;
350
351    for (key, field_value) in doc.mapping_entries(mapping) {
352        let name = string_scalar(doc, key, index, "operation member name")?;
353        if !names.insert(name.clone()) {
354            return Err(YamlPatchError::structure(
355                Some(index),
356                doc.node(key).map(|node| node.span()),
357                format!("duplicate operation member {name:?}"),
358            ));
359        }
360        match name.as_str() {
361            "op" => operation = Some(string_scalar(doc, field_value, index, "`op`")?),
362            "path" => path = Some(parse_pointer_field(doc, field_value, index, "path")?),
363            "from" => from = Some(parse_pointer_field(doc, field_value, index, "from")?),
364            "value" => value = Some(parse_value_fragment(doc, field_value, index)?),
365            _ => {}
366        }
367    }
368
369    let span = doc.node(mapping).map(|node| node.span());
370    let operation = operation.ok_or_else(|| {
371        YamlPatchError::structure(Some(index), span, "patch operation is missing `op`")
372    })?;
373    let path = path.ok_or_else(|| {
374        YamlPatchError::structure(Some(index), span, "patch operation is missing `path`")
375    })?;
376    match operation.as_str() {
377        "add" => Ok(YamlPatchOperation::Add {
378            path,
379            value: required_value(value, index, span, "add")?,
380        }),
381        "remove" => Ok(YamlPatchOperation::Remove { path }),
382        "replace" => Ok(YamlPatchOperation::Replace {
383            path,
384            value: required_value(value, index, span, "replace")?,
385        }),
386        "move" => Ok(YamlPatchOperation::Move {
387            from: required_from(from, index, span, "move")?,
388            path,
389        }),
390        "copy" => Ok(YamlPatchOperation::Copy {
391            from: required_from(from, index, span, "copy")?,
392            path,
393        }),
394        "test" => Ok(YamlPatchOperation::Test {
395            path,
396            value: required_value(value, index, span, "test")?,
397        }),
398        _ => Err(YamlPatchError::structure(
399            Some(index),
400            span,
401            format!("unknown patch operation {operation:?}"),
402        )),
403    }
404}
405
406fn string_scalar(
407    doc: &YamlDoc,
408    node: NodeId,
409    index: usize,
410    field: &str,
411) -> Result<String, YamlPatchError> {
412    let span = doc.node(node).map(|node| node.span());
413    let Some(SemanticKind::Scalar { style }) = doc.semantic_kind(node) else {
414        return Err(YamlPatchError::structure(
415            Some(index),
416            span,
417            format!("{field} must be a string"),
418        ));
419    };
420    let text = doc
421        .scalar_value(node)
422        .map_err(|error| YamlPatchError::structure(Some(index), span, error.to_string()))?;
423    let tag = doc
424        .resolved_tag(node)
425        .map_err(|error| YamlPatchError::structure(Some(index), span, error.to_string()))?;
426    let resolved = resolve_scalar(&text, style, tag.as_deref())
427        .map_err(|error| YamlPatchError::structure(Some(index), span, error.to_string()))?;
428    if resolved != ResolvedScalar::String {
429        return Err(YamlPatchError::structure(
430            Some(index),
431            span,
432            format!("{field} must be a string"),
433        ));
434    }
435    Ok(text.into_owned())
436}
437
438fn parse_pointer_field(
439    doc: &YamlDoc,
440    node: NodeId,
441    index: usize,
442    field: &str,
443) -> Result<JsonPointer, YamlPatchError> {
444    let span = doc.node(node).map(|node| node.span());
445    let text = string_scalar(doc, node, index, &format!("`{field}`"))?;
446    JsonPointer::parse(&text)
447        .map_err(|error| YamlPatchError::structure(Some(index), span, error.to_string()))
448}
449
450fn parse_value_fragment(
451    doc: &YamlDoc,
452    node: NodeId,
453    index: usize,
454) -> Result<YamlFragment, YamlPatchError> {
455    let span = doc.node(node).map(|node| node.span());
456    let mut source = doc
457        .extract_node(node)
458        .map_err(|error| YamlPatchError::structure(Some(index), span, error.to_string()))?;
459    if source.trim().is_empty() {
460        source = "null".to_owned();
461    }
462    YamlFragment::parse_owned(source)
463        .map_err(|error| YamlPatchError::structure(Some(index), span, error.to_string()))
464}
465
466fn required_value(
467    value: Option<YamlFragment>,
468    index: usize,
469    span: Option<Span>,
470    operation: &str,
471) -> Result<YamlFragment, YamlPatchError> {
472    value.ok_or_else(|| {
473        YamlPatchError::structure(
474            Some(index),
475            span,
476            format!("{operation} operation is missing `value`"),
477        )
478    })
479}
480
481fn required_from(
482    from: Option<JsonPointer>,
483    index: usize,
484    span: Option<Span>,
485    operation: &str,
486) -> Result<JsonPointer, YamlPatchError> {
487    from.ok_or_else(|| {
488        YamlPatchError::structure(
489            Some(index),
490            span,
491            format!("{operation} operation is missing `from`"),
492        )
493    })
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn parses_yaml_and_json_patch_documents() {
502        let yaml = YamlPatch::parse(
503            "- op: add\n  path: /enabled\n  value: true\n- op: remove\n  path: /old\n",
504        )
505        .unwrap();
506        let json = YamlPatch::parse(
507            r#"[{"op":"add","path":"/enabled","value":true},{"op":"remove","path":"/old"}]"#,
508        )
509        .unwrap();
510        assert_eq!(yaml, json);
511
512        let with_unknown_member =
513            YamlPatch::parse("- {op: remove, path: /old, extension: ignored}\n").unwrap();
514        assert_eq!(with_unknown_member.operations().len(), 1);
515    }
516
517    #[test]
518    fn applies_operations_sequentially_and_preserves_presentation() {
519        let patch = YamlPatch::parse(
520            "- {op: test, path: /items/0, value: a}\n\
521             - {op: add, path: /items/1, value: b}\n\
522             - {op: replace, path: /host, value: example.com}\n\
523             - {op: copy, from: /items/0, path: /items/-}\n\
524             - {op: move, from: /items/1, path: /items/0}\n\
525             - {op: remove, path: /old}\n",
526        )
527        .unwrap();
528        let mut doc = YamlDoc::parse("host: localhost # keep\nitems: [a]\nold: true\n").unwrap();
529        doc.apply_patch(0, &patch).unwrap();
530        assert_eq!(
531            doc.as_source(),
532            "host: example.com # keep\nitems: [b, a, a]\n"
533        );
534    }
535
536    #[test]
537    fn a_late_failure_rolls_back_every_operation() {
538        let patch = YamlPatch::parse(
539            "- {op: replace, path: /value, value: 2}\n\
540             - {op: test, path: /value, value: 3}\n",
541        )
542        .unwrap();
543        let input = "value: 1 # unchanged on failure\n";
544        let mut doc = YamlDoc::parse(input).unwrap();
545        let error = doc.apply_patch(0, &patch).unwrap_err();
546        assert_eq!(error.kind(), YamlPatchErrorKind::Application);
547        assert_eq!(error.operation_index(), Some(1));
548        assert!(error.span().is_some());
549        assert_eq!(doc.as_source(), input);
550    }
551
552    #[test]
553    fn supports_full_yaml_values_and_empty_nulls() {
554        let patch = YamlPatch::parse(
555            "- op: add\n  path: /tagged\n  value: !local {left: &item .inf, right: *item}\n\
556             - op: add\n  path: /empty\n  value:\n",
557        )
558        .unwrap();
559        let mut doc = YamlDoc::parse("{}\n").unwrap();
560        doc.apply_patch(0, &patch).unwrap();
561        assert_eq!(
562            doc.as_source(),
563            "{tagged: !local {left: &item .inf, right: *item}, empty: null}\n"
564        );
565    }
566
567    #[test]
568    fn batch_application_keeps_anchor_safety_and_collision_handling() {
569        let patch =
570            YamlPatch::parse("- op: add\n  path: /new\n  value: &item {value: 2, alias: *item}\n")
571                .unwrap();
572        let mut doc = YamlDoc::parse("existing: &item {value: 1}\n").unwrap();
573        doc.apply_patch(0, &patch).unwrap();
574        assert_eq!(
575            doc.as_source(),
576            "existing: &item {value: 1}\nnew: &item_1 {value: 2, alias: *item_1}\n"
577        );
578
579        let unsafe_copy =
580            YamlPatch::parse("- {op: copy, from: /existing, path: /copied}\n").unwrap();
581        let input = doc.as_source().to_owned();
582        let error = doc.apply_patch(0, &unsafe_copy).unwrap_err();
583        assert_eq!(error.operation_index(), Some(0));
584        assert!(error.to_string().contains("anchor"));
585        assert_eq!(doc.as_source(), input);
586    }
587
588    #[test]
589    fn validates_patch_structure_and_value_alias_scope() {
590        for input in [
591            "op: add\npath: /x\nvalue: 1\n",
592            "---\n[]\n---\n[]\n",
593            "!patch []\n",
594            "- scalar\n",
595            "- op: unknown\n  path: /x\n",
596            "- op: add\n  value: 1\n",
597            "- op: add\n  path: /x\n",
598            "- op: move\n  path: /x\n",
599            "- op: add\n  op: remove\n  path: /x\n  value: 1\n",
600            "- 1: member\n  op: remove\n  path: /x\n",
601            "- op: add\n  path: 1\n  value: 1\n",
602            "- op: add\n  path: x\n  value: 1\n",
603            "- &operation {op: remove, path: /x}\n",
604            "anchor: &outside value\n---\n- {op: add, path: /x, value: *outside}\n",
605        ] {
606            assert!(YamlPatch::parse(input).is_err(), "{input}");
607        }
608
609        let external_alias =
610            "- op: add\n  path: /x\n  outside: &outside value\n  value: *outside\n";
611        assert!(YamlPatch::parse(external_alias).is_err());
612
613        let syntax = YamlPatch::parse("[").unwrap_err();
614        assert_eq!(syntax.kind(), YamlPatchErrorKind::Syntax);
615    }
616
617    #[test]
618    fn programmatic_and_empty_patches_work_for_selected_documents() {
619        let operation = YamlPatchOperation::Replace {
620            path: JsonPointer::parse("/name").unwrap(),
621            value: YamlFragment::parse("updated").unwrap(),
622        };
623        let patch = YamlPatch::new(vec![operation.clone()]);
624        assert_eq!(patch.operations(), &[operation]);
625        assert_eq!(patch.clone().into_operations().len(), 1);
626
627        let mut doc = YamlDoc::parse("---\nname: first\n---\nname: second\n").unwrap();
628        doc.apply_patch(1, &patch).unwrap();
629        assert_eq!(doc.as_source(), "---\nname: first\n---\nname: updated\n");
630
631        let input = doc.as_source().to_owned();
632        doc.apply_patch(0, &YamlPatch::new(Vec::new())).unwrap();
633        assert_eq!(doc.as_source(), input);
634    }
635}