1use core::fmt;
16use std::collections::HashSet;
17
18use crate::document::Document;
19use crate::node::{NodeData, NodeId};
20use crate::scalar::{Resolved, Schema, resolve};
21use crate::tag::Tag;
22
23#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub enum Side {
26 Left,
28 Right,
30}
31
32impl fmt::Display for Side {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 f.write_str(match self {
35 Side::Left => "left",
36 Side::Right => "right",
37 })
38 }
39}
40
41#[derive(Clone, PartialEq, Debug)]
43pub enum Difference {
44 MissingKey {
46 path: String,
48 key: String,
50 present_in: Side,
52 },
53 KindMismatch {
55 path: String,
57 left: &'static str,
59 right: &'static str,
61 },
62 ValueMismatch {
64 path: String,
66 left: String,
68 right: String,
70 },
71 TagMismatch {
73 path: String,
75 left: Option<String>,
77 right: Option<String>,
79 },
80 LengthMismatch {
82 path: String,
84 left: usize,
86 right: usize,
88 },
89}
90
91impl Difference {
92 pub fn path(&self) -> &str {
94 match self {
95 Difference::MissingKey { path, .. }
96 | Difference::KindMismatch { path, .. }
97 | Difference::ValueMismatch { path, .. }
98 | Difference::TagMismatch { path, .. }
99 | Difference::LengthMismatch { path, .. } => path,
100 }
101 }
102}
103
104impl fmt::Display for Difference {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 match self {
107 Difference::MissingKey { path, key, present_in } => {
108 let absent = if *present_in == Side::Left { Side::Right } else { Side::Left };
109 write!(
110 f,
111 "{path}: key {key:?} is in the {present_in} document but not the {absent}"
112 )
113 }
114 Difference::KindMismatch { path, left, right } => {
115 write!(f, "{path}: left is a {left}, right is a {right}")
116 }
117 Difference::ValueMismatch { path, left, right } => {
118 write!(f, "{path}: {left} != {right}")
119 }
120 Difference::TagMismatch { path, left, right } => {
121 let show = |t: &Option<String>| t.clone().unwrap_or_else(|| "<untagged>".into());
122 write!(f, "{path}: tag {} != {}", show(left), show(right))
123 }
124 Difference::LengthMismatch { path, left, right } => {
125 write!(f, "{path}: sequence length {left} != {right}")
126 }
127 }
128 }
129}
130
131#[derive(Clone, Copy, Debug)]
133pub struct CompareOptions {
134 pub ignore_key_order: bool,
137 pub compare_tags: bool,
141 pub schema: Schema,
143 pub float_tolerance: Option<f64>,
146 pub max_differences: usize,
149}
150
151impl Default for CompareOptions {
152 fn default() -> Self {
153 Self {
154 ignore_key_order: true,
155 compare_tags: true,
156 schema: Schema::default(),
157 float_tolerance: None,
158 max_differences: 50,
159 }
160 }
161}
162
163#[derive(Clone, Debug, Default)]
165pub struct Comparison {
166 pub differences: Vec<Difference>,
168 pub truncated: bool,
170}
171
172impl Comparison {
173 pub fn is_equal(&self) -> bool {
175 self.differences.is_empty()
176 }
177}
178
179impl fmt::Display for Comparison {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 if self.differences.is_empty() {
182 return f.write_str("documents are equal");
183 }
184 writeln!(f, "{} difference(s):", self.differences.len())?;
185 for d in &self.differences {
186 writeln!(f, " {d}")?;
187 }
188 if self.truncated {
189 write!(f, " ... (stopped early; more differences remain)")?;
190 }
191 Ok(())
192 }
193}
194
195fn kind_of(doc: &Document, id: NodeId) -> &'static str {
197 match &doc.resolved(id).data {
198 NodeData::Scalar { .. } => "scalar",
199 NodeData::Sequence { .. } => "sequence",
200 NodeData::Mapping { .. } => "mapping",
201 NodeData::Alias(_) => "alias",
202 }
203}
204
205fn tag_string(tag: Option<&Tag>) -> Option<String> {
206 tag.map(|t| t.full())
207}
208
209fn scalars_equal(left: Resolved, right: Resolved, tolerance: Option<f64>) -> bool {
211 use Resolved::*;
212 match (left, right) {
213 (Null, Null) | (String, String) => true,
214 (Bool(a), Bool(b)) => a == b,
215
216 (Uint(a, _), Uint(b, _)) => a == b,
219 (Int(a, _), Int(b, _)) => a == b,
220 (Uint(a, _), Int(b, _)) | (Int(b, _), Uint(a, _)) => i128::from(a) == i128::from(b),
221
222 (Double(a), Double(b)) => floats_equal(a, b, tolerance),
223
224 (Double(d), Uint(u, _)) | (Uint(u, _), Double(d)) => {
227 d.fract() == 0.0 && d >= 0.0 && (d as u64) == u && (u as f64) == d
228 }
229 (Double(d), Int(i, _)) | (Int(i, _), Double(d)) => {
230 d.fract() == 0.0 && (d as i64) == i && (i as f64) == d
231 }
232
233 _ => false,
234 }
235}
236
237fn floats_equal(a: f64, b: f64, tolerance: Option<f64>) -> bool {
238 if a.is_nan() && b.is_nan() {
240 return true;
241 }
242 match tolerance {
243 None => a == b,
244 Some(tol) => {
245 if a == b {
246 return true;
247 }
248 if !a.is_finite() || !b.is_finite() {
249 return false;
250 }
251 let scale = a.abs().max(b.abs());
252 (a - b).abs() <= tol * scale.max(1.0)
253 }
254 }
255}
256
257struct Comparer<'a> {
258 left: &'a Document,
259 right: &'a Document,
260 options: CompareOptions,
261 out: Comparison,
262 seen: HashSet<(NodeId, NodeId)>,
265}
266
267impl Comparer<'_> {
268 fn full(&self) -> bool {
269 self.out.differences.len() >= self.options.max_differences
270 }
271
272 fn push(&mut self, d: Difference) {
273 if self.full() {
274 self.out.truncated = true;
275 return;
276 }
277 self.out.differences.push(d);
278 }
279
280 fn compare(&mut self, path: &str, left: NodeId, right: NodeId) {
281 if self.full() {
282 self.out.truncated = true;
283 return;
284 }
285
286 let l = self.left.resolve(left);
288 let r = self.right.resolve(right);
289 if !self.seen.insert((l, r)) {
290 return;
291 }
292
293 if self.options.compare_tags {
294 let lt = tag_string(self.left.node(l).tag.as_ref());
295 let rt = tag_string(self.right.node(r).tag.as_ref());
296 if lt != rt {
297 self.push(Difference::TagMismatch { path: path.to_string(), left: lt, right: rt });
298 }
299 }
300
301 match (&self.left.node(l).data, &self.right.node(r).data) {
302 (
303 NodeData::Scalar { value: lv, style: ls },
304 NodeData::Scalar { value: rv, style: rs },
305 ) => {
306 let lr = resolve(lv, *ls, self.options.schema);
307 let rr = resolve(rv, *rs, self.options.schema);
308 let same = if matches!(lr, Resolved::String) && matches!(rr, Resolved::String) {
309 lv == rv
311 } else {
312 scalars_equal(lr, rr, self.options.float_tolerance)
313 };
314 if !same {
315 self.push(Difference::ValueMismatch {
316 path: path.to_string(),
317 left: format!("{lv:?}"),
318 right: format!("{rv:?}"),
319 });
320 }
321 }
322
323 (NodeData::Sequence { items: li, .. }, NodeData::Sequence { items: ri, .. }) => {
324 let (li, ri) = (li.clone(), ri.clone());
325 if li.len() != ri.len() {
326 self.push(Difference::LengthMismatch {
327 path: path.to_string(),
328 left: li.len(),
329 right: ri.len(),
330 });
331 }
332 for (idx, (a, b)) in li.iter().zip(ri.iter()).enumerate() {
333 let child = format!("{path}/{idx}");
334 self.compare(&child, *a, *b);
335 }
336 }
337
338 (NodeData::Mapping { entries: le, .. }, NodeData::Mapping { entries: re, .. }) => {
339 let le = le.clone();
340 let re = re.clone();
341
342 let key_of = |doc: &Document, id: NodeId| -> String {
343 doc.resolved(id).as_str().unwrap_or("<complex key>").to_string()
344 };
345
346 let left_keys: Vec<String> = le.iter().map(|e| key_of(self.left, e.key)).collect();
347 let right_keys: Vec<String> =
348 re.iter().map(|e| key_of(self.right, e.key)).collect();
349
350 for (idx, key) in left_keys.iter().enumerate() {
351 let child = format!("{path}/{key}");
352 match right_keys.iter().position(|k| k == key) {
353 Some(pos) => {
354 if !self.options.ignore_key_order && pos != idx {
355 self.push(Difference::MissingKey {
356 path: path.to_string(),
357 key: format!("{key} (at position {idx} vs {pos})"),
358 present_in: Side::Left,
359 });
360 }
361 self.compare(&child, le[idx].value, re[pos].value);
362 }
363 None => self.push(Difference::MissingKey {
364 path: path.to_string(),
365 key: key.clone(),
366 present_in: Side::Left,
367 }),
368 }
369 }
370 for key in &right_keys {
371 if !left_keys.contains(key) {
372 self.push(Difference::MissingKey {
373 path: path.to_string(),
374 key: key.clone(),
375 present_in: Side::Right,
376 });
377 }
378 }
379 }
380
381 _ => self.push(Difference::KindMismatch {
382 path: path.to_string(),
383 left: kind_of(self.left, l),
384 right: kind_of(self.right, r),
385 }),
386 }
387 }
388}
389
390pub fn compare_from(
392 left: &Document,
393 left_root: NodeId,
394 right: &Document,
395 right_root: NodeId,
396 options: CompareOptions,
397) -> Comparison {
398 let mut c = Comparer { left, right, options, out: Comparison::default(), seen: HashSet::new() };
399 c.compare("", left_root, right_root);
400 c.out
401}
402
403pub fn compare(left: &Document, right: &Document, options: CompareOptions) -> Comparison {
407 match (left.root(), right.root()) {
408 (Some(l), Some(r)) => compare_from(left, l, right, r, options),
409 (None, None) => Comparison::default(),
410 (l, _) => Comparison {
411 differences: vec![Difference::KindMismatch {
412 path: String::new(),
413 left: if l.is_some() { "document" } else { "empty" },
414 right: if l.is_some() { "empty" } else { "document" },
415 }],
416 truncated: false,
417 },
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use crate::parse::parse_document;
425
426 fn cmp(a: &str, b: &str) -> Comparison {
427 let (da, db) = (parse_document(a).unwrap(), parse_document(b).unwrap());
428 compare(&da, &db, CompareOptions::default())
429 }
430
431 fn cmp_with(a: &str, b: &str, options: CompareOptions) -> Comparison {
432 let (da, db) = (parse_document(a).unwrap(), parse_document(b).unwrap());
433 compare(&da, &db, options)
434 }
435
436 #[test]
437 fn identical_documents_are_equal() {
438 assert!(cmp("a: 1\nb: two\n", "a: 1\nb: two\n").is_equal());
439 }
440
441 #[test]
442 fn presentation_differences_are_ignored() {
443 let r = cmp("a: {x: 1, y: 2}\n", "a:\n x: 1\n y: 2\n");
446 assert!(r.is_equal(), "{r}");
447
448 let r = cmp("s: [1, 2, 3]\n", "s:\n - 1\n - 2\n - 3\n");
449 assert!(r.is_equal(), "{r}");
450 }
451
452 #[test]
453 fn integer_width_is_not_part_of_the_value() {
454 assert!(cmp("a: 42\n", "a: 42\n").is_equal());
457 assert!(cmp("a: 0x2a\n", "a: 42\n").is_equal(), "hex and decimal 42");
458 }
459
460 #[test]
461 fn quoted_and_unquoted_numbers_differ() {
462 let r = cmp("a: 1\n", "a: '1'\n");
464 assert!(!r.is_equal());
465 assert!(matches!(r.differences[0], Difference::ValueMismatch { .. }));
466 }
467
468 #[test]
469 fn key_order_is_ignored_by_default() {
470 assert!(cmp("a: 1\nb: 2\n", "b: 2\na: 1\n").is_equal());
471 }
472
473 #[test]
474 fn key_order_can_be_enforced() {
475 let options = CompareOptions { ignore_key_order: false, ..Default::default() };
476 let r = cmp_with("a: 1\nb: 2\n", "b: 2\na: 1\n", options);
477 assert!(!r.is_equal(), "order-sensitive comparison should notice");
478 }
479
480 #[test]
481 fn missing_keys_are_reported_from_both_sides() {
482 let r = cmp("a: 1\nb: 2\n", "a: 1\nc: 3\n");
483 assert_eq!(r.differences.len(), 2);
484 assert!(r.differences.iter().any(|d| matches!(
485 d,
486 Difference::MissingKey { key, present_in: Side::Left, .. } if key == "b"
487 )));
488 assert!(r.differences.iter().any(|d| matches!(
489 d,
490 Difference::MissingKey { key, present_in: Side::Right, .. } if key == "c"
491 )));
492 }
493
494 #[test]
495 fn tags_are_compared() {
496 let a = "%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\nx: 1\n...\n";
497 let b = "%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.0.0\nx: 1\n...\n";
498 let r = cmp(a, b);
499 assert!(!r.is_equal(), "differing tags must be reported");
500 assert!(matches!(r.differences[0], Difference::TagMismatch { .. }));
501 }
502
503 #[test]
504 fn tag_comparison_can_be_disabled() {
505 let a = "%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.1.0\nx: 1\n...\n";
506 let b = "%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n--- !core/asdf-1.0.0\nx: 1\n...\n";
507 let options = CompareOptions { compare_tags: false, ..Default::default() };
508 assert!(cmp_with(a, b, options).is_equal());
509 }
510
511 #[test]
512 fn aliases_are_dereferenced() {
513 let aliased = "shared: &a {x: 1}\nother: *a\n";
516 let expanded = "shared: {x: 1}\nother: {x: 1}\n";
517 let r = cmp(aliased, expanded);
518 assert!(r.is_equal(), "{r}");
519 }
520
521 #[test]
522 fn sequence_length_and_order_matter() {
523 let r = cmp("s: [1, 2, 3]\n", "s: [1, 2]\n");
524 assert!(r.differences.iter().any(|d| matches!(d, Difference::LengthMismatch { .. })));
525
526 let r = cmp("s: [1, 2]\n", "s: [2, 1]\n");
528 assert!(!r.is_equal());
529 }
530
531 #[test]
532 fn kind_mismatches_are_reported() {
533 let r = cmp("a: 1\n", "a: [1]\n");
534 assert!(matches!(r.differences[0], Difference::KindMismatch { .. }));
535 }
536
537 #[test]
538 fn nan_equals_nan() {
539 assert!(cmp("a: nan\n", "a: nan\n").is_equal());
541 }
542
543 #[test]
544 fn float_tolerance_is_respected() {
545 let strict = cmp("a: 1.0000001\n", "a: 1.0000002\n");
546 assert!(!strict.is_equal(), "exact comparison should differ");
547
548 let options = CompareOptions { float_tolerance: Some(1e-6), ..Default::default() };
549 assert!(cmp_with("a: 1.0000001\n", "a: 1.0000002\n", options).is_equal());
550 }
551
552 #[test]
553 fn whole_floats_equal_integers() {
554 assert!(cmp("a: 1.0\n", "a: 1\n").is_equal());
556 assert!(cmp("a: -2.0\n", "a: -2\n").is_equal());
557 assert!(!cmp("a: 1.5\n", "a: 1\n").is_equal());
558 }
559
560 #[test]
561 fn paths_locate_the_difference() {
562 let r = cmp("a:\n b:\n c: 1\n", "a:\n b:\n c: 2\n");
563 assert_eq!(r.differences.len(), 1);
564 assert_eq!(r.differences[0].path(), "/a/b/c");
565 }
566
567 #[test]
568 fn reports_are_bounded() {
569 let mut a = String::new();
570 let mut b = String::new();
571 for i in 0..200 {
572 a.push_str(&format!("k{i}: 1\n"));
573 b.push_str(&format!("k{i}: 2\n"));
574 }
575 let r = cmp(&a, &b);
576 assert!(r.truncated, "an unbounded report would be unreadable");
577 assert!(r.differences.len() <= CompareOptions::default().max_differences);
578 }
579
580 #[test]
581 fn display_is_readable() {
582 let r = cmp("a: 1\n", "a: 2\n");
583 let text = r.to_string();
584 assert!(text.contains("/a"), "{text}");
585 assert!(text.contains("1 difference"), "{text}");
586 }
587
588 #[test]
589 fn shared_structure_does_not_loop() {
590 let doc = "a: &x {p: 1}\nb: *x\nc: *x\nd: *x\n";
593 assert!(cmp(doc, doc).is_equal());
594 }
595}