Skip to main content

yaml_rt_core/
value.rs

1use std::cmp::Ordering;
2use std::collections::{BTreeMap, HashSet};
3use std::fmt;
4
5use crate::{CollectionStyle, NodeId, SemanticKind, YamlDoc, YamlScalarStyle};
6
7const NULL_TAG: &str = "tag:yaml.org,2002:null";
8const BOOL_TAG: &str = "tag:yaml.org,2002:bool";
9const INT_TAG: &str = "tag:yaml.org,2002:int";
10const FLOAT_TAG: &str = "tag:yaml.org,2002:float";
11const STR_TAG: &str = "tag:yaml.org,2002:str";
12const SEQ_TAG: &str = "tag:yaml.org,2002:seq";
13const MAP_TAG: &str = "tag:yaml.org,2002:map";
14
15/// An exact, finite YAML number normalized for semantic comparison.
16///
17/// Ordering compares mathematical values without floating-point conversion.
18/// Its display form is canonical JSON-compatible decimal syntax.
19#[derive(Debug, Clone)]
20pub struct YamlNumber {
21    negative: bool,
22    digits: String,
23    exponent: i64,
24    integer_syntax: bool,
25}
26
27impl YamlNumber {
28    /// Returns whether the source used YAML integer rather than float syntax.
29    #[must_use]
30    pub const fn has_integer_syntax(&self) -> bool {
31        self.integer_syntax
32    }
33
34    /// Converts an integer-syntax number to `i128` when it fits.
35    #[must_use]
36    pub fn as_i128(&self) -> Option<i128> {
37        if !self.integer_syntax || self.exponent < 0 {
38            return None;
39        }
40        let mut text = self.digits.clone();
41        text.extend(std::iter::repeat_n(
42            '0',
43            usize::try_from(self.exponent).ok()?,
44        ));
45        if self.negative {
46            text.insert(0, '-');
47        }
48        text.parse().ok()
49    }
50
51    /// Converts a non-negative integer-syntax number to `u128` when it fits.
52    #[must_use]
53    pub fn as_u128(&self) -> Option<u128> {
54        if self.negative || !self.integer_syntax || self.exponent < 0 {
55            return None;
56        }
57        let mut text = self.digits.clone();
58        text.extend(std::iter::repeat_n(
59            '0',
60            usize::try_from(self.exponent).ok()?,
61        ));
62        text.parse().ok()
63    }
64
65    /// Converts this finite number to an `f64`.
66    #[must_use]
67    pub fn as_f64(&self) -> Option<f64> {
68        let sign = if self.negative { "-" } else { "" };
69        format!("{sign}{}e{}", self.digits, self.exponent)
70            .parse()
71            .ok()
72    }
73}
74
75impl PartialEq for YamlNumber {
76    fn eq(&self, other: &Self) -> bool {
77        self.negative == other.negative
78            && self.digits == other.digits
79            && self.exponent == other.exponent
80    }
81}
82
83impl Eq for YamlNumber {}
84
85impl PartialOrd for YamlNumber {
86    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
87        Some(self.cmp(other))
88    }
89}
90
91impl Ord for YamlNumber {
92    fn cmp(&self, other: &Self) -> Ordering {
93        if self.digits == "0" && other.digits == "0" {
94            return Ordering::Equal;
95        }
96        if self.negative != other.negative {
97            return if self.negative {
98                Ordering::Less
99            } else {
100                Ordering::Greater
101            };
102        }
103        let magnitude = compare_number_magnitude(self, other);
104        if self.negative {
105            magnitude.reverse()
106        } else {
107            magnitude
108        }
109    }
110}
111
112impl fmt::Display for YamlNumber {
113    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114        if self.negative {
115            formatter.write_str("-")?;
116        }
117        formatter.write_str(&self.digits)?;
118        if self.integer_syntax {
119            for _ in 0..self.exponent {
120                formatter.write_str("0")?;
121            }
122            Ok(())
123        } else {
124            write!(formatter, "e{}", self.exponent)
125        }
126    }
127}
128
129fn compare_number_magnitude(left: &YamlNumber, right: &YamlNumber) -> Ordering {
130    let left_places = left.digits.len() as i128 + i128::from(left.exponent);
131    let right_places = right.digits.len() as i128 + i128::from(right.exponent);
132    match left_places.cmp(&right_places) {
133        Ordering::Equal => {}
134        ordering => return ordering,
135    }
136    let compared = left.digits.len().max(right.digits.len());
137    let left_bytes = left.digits.as_bytes();
138    let right_bytes = right.digits.as_bytes();
139    for index in 0..compared {
140        let left = left_bytes.get(index).copied().unwrap_or(b'0');
141        let right = right_bytes.get(index).copied().unwrap_or(b'0');
142        match left.cmp(&right) {
143            Ordering::Equal => {}
144            ordering => return ordering,
145        }
146    }
147    Ordering::Equal
148}
149
150/// A non-finite YAML float, which is outside the JSON-compatible data model.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum NonFiniteFloat {
153    /// Positive infinity.
154    PositiveInfinity,
155    /// Negative infinity.
156    NegativeInfinity,
157    /// Not a number.
158    NaN,
159}
160
161/// YAML 1.2 core-schema interpretation of a scalar.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum ResolvedScalar {
164    /// YAML null.
165    Null,
166    /// YAML boolean.
167    Bool(bool),
168    /// A finite integer or float.
169    Number(YamlNumber),
170    /// A YAML infinity or NaN spelling.
171    NonFinite(NonFiniteFloat),
172    /// A string scalar.
173    String,
174}
175
176/// Failure to resolve a scalar according to the YAML core schema.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct ScalarResolveError {
179    message: String,
180}
181
182impl ScalarResolveError {
183    fn new(message: impl Into<String>) -> Self {
184        Self {
185            message: message.into(),
186        }
187    }
188}
189
190impl fmt::Display for ScalarResolveError {
191    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192        formatter.write_str(&self.message)
193    }
194}
195
196impl std::error::Error for ScalarResolveError {}
197
198/// Resolves a decoded scalar value using the YAML 1.2 core schema.
199///
200/// # Errors
201///
202/// Returns an error when an explicit tag is unsupported or the scalar spelling
203/// is invalid for its explicit core-schema tag.
204pub fn resolve_scalar(
205    value: &str,
206    style: YamlScalarStyle,
207    tag: Option<&str>,
208) -> Result<ResolvedScalar, ScalarResolveError> {
209    if tag == Some(STR_TAG) {
210        return Ok(ResolvedScalar::String);
211    }
212    if let Some(tag) = tag
213        && !matches!(tag, NULL_TAG | BOOL_TAG | INT_TAG | FLOAT_TAG)
214    {
215        return Err(ScalarResolveError::new(format!(
216            "unsupported scalar tag `{tag}`"
217        )));
218    }
219    if style != YamlScalarStyle::Plain && tag.is_none() {
220        return Ok(ResolvedScalar::String);
221    }
222    if tag == Some(NULL_TAG)
223        || tag.is_none() && matches!(value, "" | "~" | "null" | "Null" | "NULL")
224    {
225        return if matches!(value, "" | "~" | "null" | "Null" | "NULL") {
226            Ok(ResolvedScalar::Null)
227        } else {
228            Err(ScalarResolveError::new("invalid null scalar"))
229        };
230    }
231    if tag == Some(BOOL_TAG) || tag.is_none() {
232        match value {
233            "true" | "True" | "TRUE" => return Ok(ResolvedScalar::Bool(true)),
234            "false" | "False" | "FALSE" => return Ok(ResolvedScalar::Bool(false)),
235            _ if tag == Some(BOOL_TAG) => {
236                return Err(ScalarResolveError::new("invalid boolean scalar"));
237            }
238            _ => {}
239        }
240    }
241    if tag == Some(INT_TAG) || tag.is_none() {
242        if let Some(number) = parse_integer(value) {
243            return Ok(ResolvedScalar::Number(number));
244        }
245        if tag == Some(INT_TAG) {
246            return Err(ScalarResolveError::new("invalid integer scalar"));
247        }
248    }
249    if tag == Some(FLOAT_TAG) || tag.is_none() && looks_like_float(value) {
250        if let Some(special) = parse_non_finite(value) {
251            return Ok(ResolvedScalar::NonFinite(special));
252        }
253        if let Some(number) = parse_decimal(value, false) {
254            return Ok(ResolvedScalar::Number(number));
255        }
256        if tag == Some(FLOAT_TAG) {
257            return Err(ScalarResolveError::new("invalid float scalar"));
258        }
259    }
260    Ok(ResolvedScalar::String)
261}
262
263fn parse_integer(value: &str) -> Option<YamlNumber> {
264    let normalized = value.replace('_', "");
265    let (negative, unsigned) = strip_sign(&normalized);
266    let (radix, digits) = if let Some(rest) = unsigned.strip_prefix("0x") {
267        (16, rest)
268    } else if let Some(rest) = unsigned.strip_prefix("0o") {
269        (8, rest)
270    } else if let Some(rest) = unsigned.strip_prefix("0b") {
271        (2, rest)
272    } else {
273        (10, unsigned)
274    };
275    if digits.is_empty()
276        || !digits.chars().all(|character| character.is_digit(radix))
277        || radix == 10 && digits.len() > 1 && digits.starts_with('0')
278    {
279        return None;
280    }
281    let decimal = if radix == 10 {
282        digits.to_owned()
283    } else {
284        radix_to_decimal(digits, radix)?
285    };
286    normalize_number(negative, &decimal, 0, true)
287}
288
289fn radix_to_decimal(digits: &str, radix: u32) -> Option<String> {
290    let mut decimal = vec![0_u8];
291    for character in digits.chars() {
292        let digit = character.to_digit(radix)?;
293        let mut carry = digit;
294        for value in decimal.iter_mut().rev() {
295            let next = u32::from(*value) * radix + carry;
296            *value = u8::try_from(next % 10).ok()?;
297            carry = next / 10;
298        }
299        while carry > 0 {
300            decimal.insert(0, u8::try_from(carry % 10).ok()?);
301            carry /= 10;
302        }
303    }
304    Some(
305        decimal
306            .into_iter()
307            .map(|digit| char::from(b'0' + digit))
308            .collect(),
309    )
310}
311
312fn parse_decimal(value: &str, integer_syntax: bool) -> Option<YamlNumber> {
313    let normalized = value.replace('_', "");
314    let (negative, unsigned) = strip_sign(&normalized);
315    let (mantissa, exponent) = match unsigned.find(['e', 'E']) {
316        Some(index) => {
317            let exponent = unsigned[index + 1..].parse::<i64>().ok()?;
318            (&unsigned[..index], exponent)
319        }
320        None => (unsigned, 0),
321    };
322    let (whole, fraction) = match mantissa.split_once('.') {
323        Some(parts) => parts,
324        None => (mantissa, ""),
325    };
326    if whole.is_empty() && fraction.is_empty()
327        || !whole.chars().all(|character| character.is_ascii_digit())
328        || !fraction.chars().all(|character| character.is_ascii_digit())
329    {
330        return None;
331    }
332    let digits = format!("{whole}{fraction}");
333    let exponent = exponent.checked_sub(i64::try_from(fraction.len()).ok()?)?;
334    normalize_number(negative, &digits, exponent, integer_syntax)
335}
336
337fn normalize_number(
338    mut negative: bool,
339    digits: &str,
340    mut exponent: i64,
341    integer_syntax: bool,
342) -> Option<YamlNumber> {
343    let mut digits = digits.trim_start_matches('0').to_owned();
344    if digits.is_empty() {
345        negative = false;
346        digits.push('0');
347        exponent = 0;
348    } else {
349        while digits.ends_with('0') {
350            digits.pop();
351            exponent = exponent.checked_add(1)?;
352        }
353    }
354    Some(YamlNumber {
355        negative,
356        digits,
357        exponent,
358        integer_syntax,
359    })
360}
361
362fn strip_sign(value: &str) -> (bool, &str) {
363    if let Some(rest) = value.strip_prefix('-') {
364        (true, rest)
365    } else {
366        (false, value.strip_prefix('+').unwrap_or(value))
367    }
368}
369
370fn looks_like_float(value: &str) -> bool {
371    value.contains(['.', 'e', 'E'])
372}
373
374fn parse_non_finite(value: &str) -> Option<NonFiniteFloat> {
375    match value {
376        ".inf" | ".Inf" | ".INF" | "+.inf" | "+.Inf" | "+.INF" => {
377            Some(NonFiniteFloat::PositiveInfinity)
378        }
379        "-.inf" | "-.Inf" | "-.INF" => Some(NonFiniteFloat::NegativeInfinity),
380        ".nan" | ".NaN" | ".NAN" => Some(NonFiniteFloat::NaN),
381        _ => None,
382    }
383}
384
385/// Failure while projecting YAML nodes into the JSON-compatible data model.
386#[derive(Debug, Clone, PartialEq, Eq)]
387pub struct SemanticValueError {
388    message: String,
389}
390
391impl SemanticValueError {
392    pub(crate) fn new(message: impl Into<String>) -> Self {
393        Self {
394            message: message.into(),
395        }
396    }
397}
398
399impl fmt::Display for SemanticValueError {
400    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
401        formatter.write_str(&self.message)
402    }
403}
404
405impl std::error::Error for SemanticValueError {}
406
407/// Compares two YAML nodes using RFC 6902 JSON-value equality.
408///
409/// # Errors
410///
411/// Returns an error when either graph contains an unresolved or cyclic alias,
412/// an unsupported semantic node, or exceeds the comparison depth limit.
413pub fn semantically_equal(
414    left_doc: &YamlDoc,
415    left: NodeId,
416    right_doc: &YamlDoc,
417    right: NodeId,
418) -> Result<bool, SemanticValueError> {
419    enum CompareAction {
420        Compare(NodeId, NodeId, usize),
421        Exit(NodeId, NodeId),
422    }
423
424    let mut active = HashSet::new();
425    let mut pending = vec![CompareAction::Compare(left, right, 0)];
426    while let Some(action) = pending.pop() {
427        let CompareAction::Compare(left, right, depth) = action else {
428            let CompareAction::Exit(left, right) = action else {
429                unreachable!();
430            };
431            active.remove(&(left, right));
432            continue;
433        };
434        if depth > 1024 {
435            return Err(SemanticValueError::new(
436                "semantic comparison recursion limit exceeded",
437            ));
438        }
439        let left = resolve_alias_chain(left_doc, left)?;
440        let right = resolve_alias_chain(right_doc, right)?;
441        if !active.insert((left, right)) {
442            return Err(SemanticValueError::new(
443                "cyclic YAML values are not JSON-compatible",
444            ));
445        }
446        match (left_doc.semantic_kind(left), right_doc.semantic_kind(right)) {
447            (
448                Some(SemanticKind::Scalar { style: left_style }),
449                Some(SemanticKind::Scalar { style: right_style }),
450            ) => {
451                let left_scalar = resolved_scalar_at(left_doc, left, left_style)?;
452                let right_scalar = resolved_scalar_at(right_doc, right, right_style)?;
453                if matches!(left_scalar, ResolvedScalar::NonFinite(_))
454                    || matches!(right_scalar, ResolvedScalar::NonFinite(_))
455                {
456                    return Err(SemanticValueError::new(
457                        "infinities and NaN are not JSON-compatible",
458                    ));
459                }
460                active.remove(&(left, right));
461                if left_scalar != right_scalar {
462                    return Ok(false);
463                }
464            }
465            (
466                Some(SemanticKind::Sequence { style: left_style }),
467                Some(SemanticKind::Sequence { style: right_style }),
468            ) => {
469                validate_collection_tag(left_doc, left, left_style, false)?;
470                validate_collection_tag(right_doc, right, right_style, false)?;
471                let left_items = left_doc.sequence_items(left).collect::<Vec<_>>();
472                let right_items = right_doc.sequence_items(right).collect::<Vec<_>>();
473                if left_items.len() != right_items.len() {
474                    return Ok(false);
475                }
476                pending.push(CompareAction::Exit(left, right));
477                for (left_item, right_item) in left_items.into_iter().zip(right_items).rev() {
478                    pending.push(CompareAction::Compare(left_item, right_item, depth + 1));
479                }
480            }
481            (
482                Some(SemanticKind::Mapping { style: left_style }),
483                Some(SemanticKind::Mapping { style: right_style }),
484            ) => {
485                validate_collection_tag(left_doc, left, left_style, true)?;
486                validate_collection_tag(right_doc, right, right_style, true)?;
487                let left_entries = json_mapping(left_doc, left)?;
488                let right_entries = json_mapping(right_doc, right)?;
489                if left_entries.len() != right_entries.len() {
490                    return Ok(false);
491                }
492                let mut children = Vec::with_capacity(left_entries.len());
493                for (key, left_value) in left_entries {
494                    let Some(right_value) = right_entries.get(&key).copied() else {
495                        return Ok(false);
496                    };
497                    children.push((left_value, right_value));
498                }
499                pending.push(CompareAction::Exit(left, right));
500                for (left_value, right_value) in children.into_iter().rev() {
501                    pending.push(CompareAction::Compare(left_value, right_value, depth + 1));
502                }
503            }
504            (Some(SemanticKind::Alias), _) | (_, Some(SemanticKind::Alias)) => {
505                unreachable!("aliases are resolved before comparison")
506            }
507            (Some(_), Some(_)) => return Ok(false),
508            _ => return Err(SemanticValueError::new("unknown semantic YAML node")),
509        }
510    }
511    Ok(true)
512}
513
514fn resolved_scalar_at(
515    doc: &YamlDoc,
516    node: NodeId,
517    style: YamlScalarStyle,
518) -> Result<ResolvedScalar, SemanticValueError> {
519    let value = doc
520        .scalar_value(node)
521        .map_err(|error| SemanticValueError::new(error.to_string()))?;
522    let tag = doc
523        .resolved_tag(node)
524        .map_err(|error| SemanticValueError::new(error.to_string()))?;
525    resolve_scalar(&value, style, tag.as_deref())
526        .map_err(|error| SemanticValueError::new(error.to_string()))
527}
528
529fn resolve_alias_chain(doc: &YamlDoc, mut node: NodeId) -> Result<NodeId, SemanticValueError> {
530    let mut seen = HashSet::new();
531    while matches!(doc.semantic_kind(node), Some(SemanticKind::Alias)) {
532        if !seen.insert(node) {
533            return Err(SemanticValueError::new("cyclic alias chain"));
534        }
535        node = doc.resolve_alias(node).ok_or_else(|| {
536            SemanticValueError::new(format!(
537                "unresolved alias `*{}`",
538                doc.alias_name(node).unwrap_or_default()
539            ))
540        })?;
541    }
542    Ok(node)
543}
544
545fn json_mapping(
546    doc: &YamlDoc,
547    mapping: NodeId,
548) -> Result<BTreeMap<String, NodeId>, SemanticValueError> {
549    let mut entries = BTreeMap::new();
550    for (key, value) in doc.mapping_entries(mapping) {
551        let key = resolve_alias_chain(doc, key)?;
552        let Some(SemanticKind::Scalar { style }) = doc.semantic_kind(key) else {
553            return Err(SemanticValueError::new("mapping contains a non-string key"));
554        };
555        if resolved_scalar_at(doc, key, style)? != ResolvedScalar::String {
556            return Err(SemanticValueError::new("mapping contains a non-string key"));
557        }
558        let key = doc
559            .scalar_value(key)
560            .map_err(|error| SemanticValueError::new(error.to_string()))?
561            .into_owned();
562        if entries.insert(key.clone(), value).is_some() {
563            return Err(SemanticValueError::new(format!(
564                "mapping contains duplicate key `{key}`"
565            )));
566        }
567    }
568    Ok(entries)
569}
570
571fn validate_collection_tag(
572    doc: &YamlDoc,
573    node: NodeId,
574    _style: CollectionStyle,
575    mapping: bool,
576) -> Result<(), SemanticValueError> {
577    let tag = doc
578        .resolved_tag(node)
579        .map_err(|error| SemanticValueError::new(error.to_string()))?;
580    let expected = if mapping { MAP_TAG } else { SEQ_TAG };
581    if tag.as_deref().is_some_and(|tag| tag != expected) {
582        return Err(SemanticValueError::new(format!(
583            "custom-tagged collections are not JSON-compatible: `{}`",
584            tag.as_deref().unwrap_or_default()
585        )));
586    }
587    Ok(())
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    fn nested_block_mapping(depth: usize, value: &str) -> String {
595        let mut yaml = String::new();
596        for level in 0..depth {
597            yaml.push_str(&"  ".repeat(level));
598            yaml.push_str("key:\n");
599        }
600        yaml.push_str(&"  ".repeat(depth));
601        yaml.push_str(value);
602        yaml.push('\n');
603        yaml
604    }
605
606    fn roots_are_equal(left: &YamlDoc, right: &YamlDoc) -> Result<bool, SemanticValueError> {
607        semantically_equal(
608            left,
609            left.document_root(0).unwrap().unwrap(),
610            right,
611            right.document_root(0).unwrap().unwrap(),
612        )
613    }
614
615    #[test]
616    fn exact_numbers_compare_across_yaml_spellings() {
617        let values = ["1", "1.0", "1e0", "0x1"];
618        let numbers = values
619            .into_iter()
620            .map(|value| {
621                let ResolvedScalar::Number(number) =
622                    resolve_scalar(value, YamlScalarStyle::Plain, None).unwrap()
623                else {
624                    panic!("expected number");
625                };
626                number
627            })
628            .collect::<Vec<_>>();
629        assert!(numbers.windows(2).all(|pair| pair[0] == pair[1]));
630    }
631
632    #[test]
633    fn exact_numbers_order_and_format_without_float_conversion() {
634        let number = |value| {
635            let ResolvedScalar::Number(number) =
636                resolve_scalar(value, YamlScalarStyle::Plain, None).unwrap()
637            else {
638                panic!("expected number");
639            };
640            number
641        };
642
643        assert!(number("-1e1000") < number("-9e999"));
644        assert!(number("9e999") < number("1e1000"));
645        assert_eq!(number("0x10").to_string(), "16");
646        assert_eq!(number("1.50").to_string(), "15e-1");
647        assert_eq!(number("1e1000").to_string(), "1e1000");
648    }
649
650    #[test]
651    fn semantic_equality_ignores_presentation_and_mapping_order() {
652        let left = YamlDoc::parse("a: 1\nb: ['x', true]\n").unwrap();
653        let right = YamlDoc::parse("{b: [x, TRUE], a: 1.0}\n").unwrap();
654        assert!(
655            semantically_equal(
656                &left,
657                left.document_root(0).unwrap().unwrap(),
658                &right,
659                right.document_root(0).unwrap().unwrap()
660            )
661            .unwrap()
662        );
663    }
664
665    #[test]
666    fn semantic_equality_rejects_non_string_keys() {
667        let left = YamlDoc::parse("1: value\n").unwrap();
668        let right = YamlDoc::parse("'1': value\n").unwrap();
669        assert!(
670            semantically_equal(
671                &left,
672                left.document_root(0).unwrap().unwrap(),
673                &right,
674                right.document_root(0).unwrap().unwrap()
675            )
676            .unwrap_err()
677            .to_string()
678            .contains("non-string")
679        );
680    }
681
682    #[test]
683    fn semantic_equality_handles_deep_equal_and_unequal_values_iteratively() {
684        std::thread::Builder::new()
685            .stack_size(32 * 1024 * 1024)
686            .spawn(|| {
687                let left = YamlDoc::parse(&nested_block_mapping(1024, "1")).unwrap();
688                let equal = YamlDoc::parse(&nested_block_mapping(1024, "1.0")).unwrap();
689                let unequal = YamlDoc::parse(&nested_block_mapping(1024, "2")).unwrap();
690                assert!(roots_are_equal(&left, &equal).unwrap());
691                assert!(!roots_are_equal(&left, &unequal).unwrap());
692
693                let too_deep = YamlDoc::parse(&nested_block_mapping(1025, "1")).unwrap();
694                assert!(
695                    roots_are_equal(&too_deep, &too_deep)
696                        .unwrap_err()
697                        .to_string()
698                        .contains("recursion limit")
699                );
700            })
701            .unwrap()
702            .join()
703            .unwrap();
704    }
705
706    #[test]
707    fn semantic_equality_still_rejects_cyclic_alias_values() {
708        let left = YamlDoc::parse("&root [*root]\n").unwrap();
709        let right = YamlDoc::parse("&root [*root]\n").unwrap();
710        assert!(
711            roots_are_equal(&left, &right)
712                .unwrap_err()
713                .to_string()
714                .contains("cyclic YAML values")
715        );
716    }
717}