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 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}