Skip to main content

asdf_yaml/
compare.rs

1//! Comparing two documents at the value level.
2//!
3//! Byte-level parity between YAML writers is a poor correctness criterion:
4//! flow versus block, quoting style, line breaking and scalar spelling all
5//! vary freely without changing a single value, so byte equality both fails
6//! on correct output and passes on incorrect output. The ASDF Standard's own
7//! reference corpus says as much -- its files "do not need to be
8//! byte-for-byte identical, but should represent the same values at the YAML
9//! level".
10//!
11//! This module is that comparison. It ignores presentation and is strict
12//! about meaning: tags, resolved scalar values, sequence order and the set of
13//! mapping keys.
14
15use 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/// Which document a one-sided difference came from.
24#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub enum Side {
26    /// The left-hand document.
27    Left,
28    /// The right-hand document.
29    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/// One way in which two documents differ.
42#[derive(Clone, PartialEq, Debug)]
43pub enum Difference {
44    /// A mapping key present on one side only.
45    MissingKey {
46        /// Where in the tree.
47        path: String,
48        /// The key that is absent from the other side.
49        key: String,
50        /// The side that *has* the key.
51        present_in: Side,
52    },
53    /// The two nodes are different kinds of thing.
54    KindMismatch {
55        /// Where in the tree.
56        path: String,
57        /// The left node's kind.
58        left: &'static str,
59        /// The right node's kind.
60        right: &'static str,
61    },
62    /// Two scalars resolve to different values.
63    ValueMismatch {
64        /// Where in the tree.
65        path: String,
66        /// The left value, as written.
67        left: String,
68        /// The right value, as written.
69        right: String,
70    },
71    /// The nodes carry different tags.
72    TagMismatch {
73        /// Where in the tree.
74        path: String,
75        /// The left tag, if any.
76        left: Option<String>,
77        /// The right tag, if any.
78        right: Option<String>,
79    },
80    /// Two sequences have different lengths.
81    LengthMismatch {
82        /// Where in the tree.
83        path: String,
84        /// The left length.
85        left: usize,
86        /// The right length.
87        right: usize,
88    },
89}
90
91impl Difference {
92    /// Where in the tree this difference is.
93    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/// How strict a comparison to make.
132#[derive(Clone, Copy, Debug)]
133pub struct CompareOptions {
134    /// Treat mappings as unordered. YAML assigns no meaning to key order, and
135    /// two writers routinely disagree about it, so this is on by default.
136    pub ignore_key_order: bool,
137    /// Require nodes to carry the same tag. On by default: in ASDF the tag is
138    /// the type, so ignoring it would let an ndarray compare equal to a plain
139    /// mapping.
140    pub compare_tags: bool,
141    /// Which scalar-resolution rules to apply to both sides.
142    pub schema: Schema,
143    /// Relative tolerance for comparing floats. `None` requires bit-equal
144    /// values (with NaN equal to NaN, which `==` would not give).
145    pub float_tolerance: Option<f64>,
146    /// Stop after this many differences, so a wholly unrelated pair of
147    /// documents does not produce an unreadable report.
148    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/// The result of a comparison.
164#[derive(Clone, Debug, Default)]
165pub struct Comparison {
166    /// Every difference found, in tree order.
167    pub differences: Vec<Difference>,
168    /// Whether the report was cut short by `max_differences`.
169    pub truncated: bool,
170}
171
172impl Comparison {
173    /// Whether the two documents represent the same values.
174    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
195/// The kind of a node, for reporting.
196fn 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
209/// Are two resolved scalars the same value?
210fn 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        // Compare integers by value, not by the width they happened to
217        // narrow to: 42 is 42 whether it landed in uint8 or int64.
218        (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        // A whole-valued float and an integer are the same number, and the
225        // two sides may legitimately have written it either way.
226        (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    // Two NaNs represent the same thing here, though `==` says otherwise.
239    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    /// Node pairs already compared, so shared and aliased structure is not
263    /// walked repeatedly and cycles cannot loop forever.
264    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        // Aliases are dereferenced, as the reference corpus prescribes.
287        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                    // Both are strings: compare the text itself.
310                    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
390/// Compare two subtrees.
391pub 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
403/// Compare two documents from their roots.
404///
405/// A document with no root compares unequal to one with a root.
406pub 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        // Flow versus block is the canonical case: the same values, written
444        // two ways, must compare equal.
445        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        // 42 narrows to uint8 and 300 to uint16; the widths must not matter,
455        // only the numbers.
456        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        // This is a real difference: one is an integer, the other a string.
463        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        // The reference corpus prescribes dereferencing aliases before
514        // comparing, so an aliased document equals its expanded form.
515        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        // Sequence order *is* semantic, unlike mapping order.
527        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        // Bare `nan` resolves as a double under libasdf's rules.
540        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        // Two writers may spell the same number differently.
555        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        // Both sides alias the same node repeatedly; comparison must
591        // terminate rather than re-walking it.
592        let doc = "a: &x {p: 1}\nb: *x\nc: *x\nd: *x\n";
593        assert!(cmp(doc, doc).is_equal());
594    }
595}