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 mutates = !matches!(operation, YamlPatchOperation::Test { .. });
264            let result = match operation {
265                YamlPatchOperation::Add { path, value } => work.add_at(document, path, value),
266                YamlPatchOperation::Remove { path } => work.remove_at(document, path),
267                YamlPatchOperation::Replace { path, value } => {
268                    work.replace_at(document, path, value)
269                }
270                YamlPatchOperation::Move { from, path } => work.move_at(document, from, path),
271                YamlPatchOperation::Copy { from, path } => work.copy_at(document, from, path),
272                YamlPatchOperation::Test { path, value } => {
273                    match work.test_at(document, path, value) {
274                        Ok(true) => Ok(()),
275                        Ok(false) => Err(crate::YamlEditError::new(format!(
276                            "test failed at {:?}: values are not semantically equal",
277                            path.as_str()
278                        ))),
279                        Err(error) => Err(error),
280                    }
281                }
282            };
283            if let Err(error) = result {
284                return Err(YamlPatchError::application(
285                    Some(index),
286                    patch.operation_span(index),
287                    error.to_string(),
288                ));
289            }
290            if mutates && let Err(error) = work.commit_edits() {
291                return Err(YamlPatchError::application(
292                    Some(index),
293                    patch.operation_span(index),
294                    error.to_string(),
295                ));
296            }
297        }
298        *self = work;
299        Ok(())
300    }
301}
302
303#[derive(Clone, Copy)]
304enum SemanticCollection {
305    Sequence,
306    Mapping,
307}
308
309fn require_undecorated_collection(
310    doc: &YamlDoc,
311    node: NodeId,
312    expected: SemanticCollection,
313    operation_index: Option<usize>,
314) -> Result<(), YamlPatchError> {
315    let span = doc.node(node).map(|node| node.span());
316    let valid_kind = matches!(
317        (expected, doc.semantic_kind(node)),
318        (
319            SemanticCollection::Sequence,
320            Some(SemanticKind::Sequence { .. })
321        ) | (
322            SemanticCollection::Mapping,
323            Some(SemanticKind::Mapping { .. })
324        )
325    );
326    if !valid_kind {
327        let expected_name = match expected {
328            SemanticCollection::Sequence => "a sequence root",
329            SemanticCollection::Mapping => "a mapping",
330        };
331        let message = if operation_index.is_some() {
332            format!("a YAML patch operation must be {expected_name}")
333        } else {
334            format!("a YAML patch must have {expected_name}")
335        };
336        return Err(YamlPatchError::structure(operation_index, span, message));
337    }
338    if doc.raw_tag(node).is_some() || doc.anchor(node).is_some() {
339        return Err(YamlPatchError::structure(
340            operation_index,
341            span,
342            "patch structural collections cannot have tags or anchors",
343        ));
344    }
345    Ok(())
346}
347
348fn parse_operation(
349    doc: &YamlDoc,
350    mapping: NodeId,
351    index: usize,
352) -> Result<YamlPatchOperation, YamlPatchError> {
353    let mut names = HashSet::new();
354    let mut operation = None;
355    let mut path = None;
356    let mut from = None;
357    let mut value = None;
358
359    for (key, field_value) in doc.mapping_entries(mapping) {
360        let name = string_scalar(doc, key, index, "operation member name")?;
361        if !names.insert(name.clone()) {
362            return Err(YamlPatchError::structure(
363                Some(index),
364                doc.node(key).map(|node| node.span()),
365                format!("duplicate operation member {name:?}"),
366            ));
367        }
368        match name.as_str() {
369            "op" => operation = Some(string_scalar(doc, field_value, index, "`op`")?),
370            "path" => path = Some(parse_pointer_field(doc, field_value, index, "path")?),
371            "from" => from = Some(parse_pointer_field(doc, field_value, index, "from")?),
372            "value" => value = Some(parse_value_fragment(doc, field_value, index)?),
373            _ => {}
374        }
375    }
376
377    let span = doc.node(mapping).map(|node| node.span());
378    let operation = operation.ok_or_else(|| {
379        YamlPatchError::structure(Some(index), span, "patch operation is missing `op`")
380    })?;
381    let path = path.ok_or_else(|| {
382        YamlPatchError::structure(Some(index), span, "patch operation is missing `path`")
383    })?;
384    match operation.as_str() {
385        "add" => Ok(YamlPatchOperation::Add {
386            path,
387            value: required_value(value, index, span, "add")?,
388        }),
389        "remove" => Ok(YamlPatchOperation::Remove { path }),
390        "replace" => Ok(YamlPatchOperation::Replace {
391            path,
392            value: required_value(value, index, span, "replace")?,
393        }),
394        "move" => Ok(YamlPatchOperation::Move {
395            from: required_from(from, index, span, "move")?,
396            path,
397        }),
398        "copy" => Ok(YamlPatchOperation::Copy {
399            from: required_from(from, index, span, "copy")?,
400            path,
401        }),
402        "test" => Ok(YamlPatchOperation::Test {
403            path,
404            value: required_value(value, index, span, "test")?,
405        }),
406        _ => Err(YamlPatchError::structure(
407            Some(index),
408            span,
409            format!("unknown patch operation {operation:?}"),
410        )),
411    }
412}
413
414fn string_scalar(
415    doc: &YamlDoc,
416    node: NodeId,
417    index: usize,
418    field: &str,
419) -> Result<String, YamlPatchError> {
420    let span = doc.node(node).map(|node| node.span());
421    let Some(SemanticKind::Scalar { style }) = doc.semantic_kind(node) else {
422        return Err(YamlPatchError::structure(
423            Some(index),
424            span,
425            format!("{field} must be a string"),
426        ));
427    };
428    let text = doc
429        .scalar_value(node)
430        .map_err(|error| YamlPatchError::structure(Some(index), span, error.to_string()))?;
431    let tag = doc
432        .resolved_tag(node)
433        .map_err(|error| YamlPatchError::structure(Some(index), span, error.to_string()))?;
434    let resolved = resolve_scalar(&text, style, tag.as_deref())
435        .map_err(|error| YamlPatchError::structure(Some(index), span, error.to_string()))?;
436    if resolved != ResolvedScalar::String {
437        return Err(YamlPatchError::structure(
438            Some(index),
439            span,
440            format!("{field} must be a string"),
441        ));
442    }
443    Ok(text.into_owned())
444}
445
446fn parse_pointer_field(
447    doc: &YamlDoc,
448    node: NodeId,
449    index: usize,
450    field: &str,
451) -> Result<JsonPointer, YamlPatchError> {
452    let span = doc.node(node).map(|node| node.span());
453    let text = string_scalar(doc, node, index, &format!("`{field}`"))?;
454    JsonPointer::parse(&text)
455        .map_err(|error| YamlPatchError::structure(Some(index), span, error.to_string()))
456}
457
458fn parse_value_fragment(
459    doc: &YamlDoc,
460    node: NodeId,
461    index: usize,
462) -> Result<YamlFragment, YamlPatchError> {
463    let span = doc.node(node).map(|node| node.span());
464    let mut source = doc
465        .extract_node(node)
466        .map_err(|error| YamlPatchError::structure(Some(index), span, error.to_string()))?;
467    if source.trim().is_empty() {
468        source = "null".to_owned();
469    }
470    YamlFragment::parse_owned(source)
471        .map_err(|error| YamlPatchError::structure(Some(index), span, error.to_string()))
472}
473
474fn required_value(
475    value: Option<YamlFragment>,
476    index: usize,
477    span: Option<Span>,
478    operation: &str,
479) -> Result<YamlFragment, YamlPatchError> {
480    value.ok_or_else(|| {
481        YamlPatchError::structure(
482            Some(index),
483            span,
484            format!("{operation} operation is missing `value`"),
485        )
486    })
487}
488
489fn required_from(
490    from: Option<JsonPointer>,
491    index: usize,
492    span: Option<Span>,
493    operation: &str,
494) -> Result<JsonPointer, YamlPatchError> {
495    from.ok_or_else(|| {
496        YamlPatchError::structure(
497            Some(index),
498            span,
499            format!("{operation} operation is missing `from`"),
500        )
501    })
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn parses_yaml_and_json_patch_documents() {
510        let yaml = YamlPatch::parse(
511            "- op: add\n  path: /enabled\n  value: true\n- op: remove\n  path: /old\n",
512        )
513        .unwrap();
514        let json = YamlPatch::parse(
515            r#"[{"op":"add","path":"/enabled","value":true},{"op":"remove","path":"/old"}]"#,
516        )
517        .unwrap();
518        assert_eq!(yaml, json);
519
520        let with_unknown_member =
521            YamlPatch::parse("- {op: remove, path: /old, extension: ignored}\n").unwrap();
522        assert_eq!(with_unknown_member.operations().len(), 1);
523    }
524
525    #[test]
526    fn applies_operations_sequentially_and_preserves_presentation() {
527        let patch = YamlPatch::parse(
528            "- {op: test, path: /items/0, value: a}\n\
529             - {op: add, path: /items/1, value: b}\n\
530             - {op: replace, path: /host, value: example.com}\n\
531             - {op: copy, from: /items/0, path: /items/-}\n\
532             - {op: move, from: /items/1, path: /items/0}\n\
533             - {op: remove, path: /old}\n",
534        )
535        .unwrap();
536        let mut doc = YamlDoc::parse("host: localhost # keep\nitems: [a]\nold: true\n").unwrap();
537        doc.apply_patch(0, &patch).unwrap();
538        assert_eq!(
539            doc.as_source(),
540            "host: example.com # keep\nitems: [b, a, a]\n"
541        );
542    }
543
544    #[test]
545    fn patch_add_indents_compact_sequence_mapping_members() {
546        let mut doc = YamlDoc::parse("services:\n  - name: api\n    port: 8080\n").unwrap();
547        let patch =
548            YamlPatch::parse("- op: add\n  path: /services/0/protocol\n  value: https\n").unwrap();
549
550        doc.apply_patch(0, &patch).unwrap();
551        assert_eq!(
552            doc.as_source(),
553            "services:\n  - name: api\n    port: 8080\n    protocol: https\n"
554        );
555        doc.commit_edits().unwrap();
556    }
557
558    #[test]
559    fn a_late_failure_rolls_back_every_operation() {
560        let patch = YamlPatch::parse(
561            "- {op: replace, path: /value, value: 2}\n\
562             - {op: test, path: /value, value: 3}\n",
563        )
564        .unwrap();
565        let input = "value: 1 # unchanged on failure\n";
566        let mut doc = YamlDoc::parse(input).unwrap();
567        let error = doc.apply_patch(0, &patch).unwrap_err();
568        assert_eq!(error.kind(), YamlPatchErrorKind::Application);
569        assert_eq!(error.operation_index(), Some(1));
570        assert!(error.span().is_some());
571        assert_eq!(doc.as_source(), input);
572    }
573
574    #[test]
575    fn later_removals_observe_earlier_structural_changes() {
576        let input = "groups:\n  first: [a, b, c]\n  second: [d, e]\ntail: keep\n";
577        for patch in [
578            "- {op: remove, path: /groups/first/0}\n- {op: remove, path: /groups/first/1}\n- {op: remove, path: /groups/second}\n",
579            "- {op: remove, path: /groups/second}\n- {op: remove, path: /groups/first/0}\n- {op: remove, path: /groups/first/1}\n",
580        ] {
581            let mut doc = YamlDoc::parse(input).unwrap();
582            doc.apply_patch(0, &YamlPatch::parse(patch).unwrap())
583                .unwrap();
584            assert_eq!(doc.as_source(), "groups:\n  first: [b]\ntail: keep\n");
585        }
586    }
587
588    // Regression examples adapted from:
589    // https://verrchu.github.io/blog/2-respectful-yaml-patching-in-rust/
590    #[test]
591    fn respectfully_patches_the_article_asset_groups() {
592        let input = "# outer comment\nasset_groups:\n  group_abc:    # group_abc comment\n    - BTC\n    - ETH\n    - SOL\n  # group_xyz outer comment\n  group_xyz:\n    -  DOGE       # asset comment\n    - PEPE\n  default:\n    # default group inner comment\n    - 1INCH\n    - ATOM\n    - LINK\n";
593        let listing = YamlPatch::parse(
594            "- {op: add, path: /asset_groups/default/2, value: BNB}\n- {op: add, path: /asset_groups/default/-, value: XRP}\n",
595        )
596        .unwrap();
597        let mut listed = YamlDoc::parse(input).unwrap();
598        listed.apply_patch(0, &listing).unwrap();
599        assert_eq!(
600            listed.as_source(),
601            "# outer comment\nasset_groups:\n  group_abc:    # group_abc comment\n    - BTC\n    - ETH\n    - SOL\n  # group_xyz outer comment\n  group_xyz:\n    -  DOGE       # asset comment\n    - PEPE\n  default:\n    # default group inner comment\n    - 1INCH\n    - ATOM\n    - BNB\n    - LINK\n    - XRP\n"
602        );
603
604        let delisting = YamlPatch::parse(
605            "- {op: remove, path: /asset_groups/group_abc/2}\n- {op: remove, path: /asset_groups/group_abc/0}\n- {op: remove, path: /asset_groups/group_xyz}\n- {op: remove, path: /asset_groups/default/1}\n",
606        )
607        .unwrap();
608        let mut delisted = YamlDoc::parse(input).unwrap();
609        delisted.apply_patch(0, &delisting).unwrap();
610        assert_eq!(
611            delisted.as_source(),
612            "# outer comment\nasset_groups:\n  group_abc:    # group_abc comment\n    - ETH\n  default:\n    # default group inner comment\n    - 1INCH\n    - LINK\n"
613        );
614    }
615
616    #[test]
617    fn article_listing_preserves_an_unterminated_final_line() {
618        let input = "asset_groups:\n  default:\n    - 1INCH\n    - ATOM\n    - LINK";
619        let patch = YamlPatch::parse(
620            "- {op: add, path: /asset_groups/default/2, value: BNB}\n- {op: add, path: /asset_groups/default/-, value: XRP}\n",
621        )
622        .unwrap();
623        let mut doc = YamlDoc::parse(input).unwrap();
624        doc.apply_patch(0, &patch).unwrap();
625        assert_eq!(
626            doc.as_source(),
627            "asset_groups:\n  default:\n    - 1INCH\n    - ATOM\n    - BNB\n    - LINK\n    - XRP"
628        );
629    }
630
631    #[test]
632    fn supports_full_yaml_values_and_empty_nulls() {
633        let patch = YamlPatch::parse(
634            "- op: add\n  path: /tagged\n  value: !local {left: &item .inf, right: *item}\n\
635             - op: add\n  path: /empty\n  value:\n",
636        )
637        .unwrap();
638        let mut doc = YamlDoc::parse("{}\n").unwrap();
639        doc.apply_patch(0, &patch).unwrap();
640        assert_eq!(
641            doc.as_source(),
642            "{tagged: !local {left: &item .inf, right: *item}, empty: null}\n"
643        );
644    }
645
646    #[test]
647    fn batch_application_keeps_anchor_safety_and_collision_handling() {
648        let patch =
649            YamlPatch::parse("- op: add\n  path: /new\n  value: &item {value: 2, alias: *item}\n")
650                .unwrap();
651        let mut doc = YamlDoc::parse("existing: &item {value: 1}\n").unwrap();
652        doc.apply_patch(0, &patch).unwrap();
653        assert_eq!(
654            doc.as_source(),
655            "existing: &item {value: 1}\nnew: &item_1 {value: 2, alias: *item_1}\n"
656        );
657
658        let unsafe_copy =
659            YamlPatch::parse("- {op: copy, from: /existing, path: /copied}\n").unwrap();
660        let input = doc.as_source().to_owned();
661        let error = doc.apply_patch(0, &unsafe_copy).unwrap_err();
662        assert_eq!(error.operation_index(), Some(0));
663        assert!(error.to_string().contains("anchor"));
664        assert_eq!(doc.as_source(), input);
665    }
666
667    #[test]
668    fn validates_patch_structure_and_value_alias_scope() {
669        for input in [
670            "op: add\npath: /x\nvalue: 1\n",
671            "---\n[]\n---\n[]\n",
672            "!patch []\n",
673            "- scalar\n",
674            "- op: unknown\n  path: /x\n",
675            "- op: add\n  value: 1\n",
676            "- op: add\n  path: /x\n",
677            "- op: move\n  path: /x\n",
678            "- op: add\n  op: remove\n  path: /x\n  value: 1\n",
679            "- 1: member\n  op: remove\n  path: /x\n",
680            "- op: add\n  path: 1\n  value: 1\n",
681            "- op: add\n  path: x\n  value: 1\n",
682            "- &operation {op: remove, path: /x}\n",
683            "anchor: &outside value\n---\n- {op: add, path: /x, value: *outside}\n",
684        ] {
685            assert!(YamlPatch::parse(input).is_err(), "{input}");
686        }
687
688        let external_alias =
689            "- op: add\n  path: /x\n  outside: &outside value\n  value: *outside\n";
690        assert!(YamlPatch::parse(external_alias).is_err());
691
692        let syntax = YamlPatch::parse("[").unwrap_err();
693        assert_eq!(syntax.kind(), YamlPatchErrorKind::Syntax);
694    }
695
696    #[test]
697    fn programmatic_and_empty_patches_work_for_selected_documents() {
698        let operation = YamlPatchOperation::Replace {
699            path: JsonPointer::parse("/name").unwrap(),
700            value: YamlFragment::parse("updated").unwrap(),
701        };
702        let patch = YamlPatch::new(vec![operation.clone()]);
703        assert_eq!(patch.operations(), &[operation]);
704        assert_eq!(patch.clone().into_operations().len(), 1);
705
706        let mut doc = YamlDoc::parse("---\nname: first\n---\nname: second\n").unwrap();
707        doc.apply_patch(1, &patch).unwrap();
708        assert_eq!(doc.as_source(), "---\nname: first\n---\nname: updated\n");
709
710        let input = doc.as_source().to_owned();
711        doc.apply_patch(0, &YamlPatch::new(Vec::new())).unwrap();
712        assert_eq!(doc.as_source(), input);
713    }
714}