1use std::collections::HashSet;
2use std::fmt;
3
4use crate::{
5 JsonPointer, NodeId, ResolvedScalar, SemanticKind, Span, YamlDoc, YamlFragment, resolve_scalar,
6};
7
8#[derive(Debug, Clone)]
10pub struct YamlPatch {
11 operations: Vec<YamlPatchOperation>,
12 operation_spans: Vec<Option<Span>>,
13}
14
15impl YamlPatch {
16 #[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 pub fn parse(input: &str) -> Result<Self, YamlPatchError> {
33 Self::parse_owned(input.to_owned())
34 }
35
36 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 #[must_use]
84 pub fn operations(&self) -> &[YamlPatchOperation] {
85 &self.operations
86 }
87
88 #[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#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum YamlPatchOperation {
110 Add {
112 path: JsonPointer,
114 value: YamlFragment,
116 },
117 Remove {
119 path: JsonPointer,
121 },
122 Replace {
124 path: JsonPointer,
126 value: YamlFragment,
128 },
129 Move {
131 from: JsonPointer,
133 path: JsonPointer,
135 },
136 Copy {
138 from: JsonPointer,
140 path: JsonPointer,
142 },
143 Test {
145 path: JsonPointer,
147 value: YamlFragment,
149 },
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum YamlPatchErrorKind {
155 Syntax,
157 Structure,
159 Application,
161}
162
163#[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 #[must_use]
215 pub const fn kind(&self) -> YamlPatchErrorKind {
216 self.kind
217 }
218
219 #[must_use]
221 pub const fn operation_index(&self) -> Option<usize> {
222 self.operation_index
223 }
224
225 #[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 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 #[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}