Skip to main content

rlsp_yaml_parser/
schema.rs

1// SPDX-License-Identifier: MIT
2
3//! YAML schema resolution — tag resolution and scalar type inference.
4//!
5//! Three standard schemas are provided:
6//! - [`FailsafeSchema`] — all scalars are strings.
7//! - [`JsonSchema`] — strict JSON-compatible type inference.
8//! - [`CoreSchema`] — YAML 1.2 Core schema (default); extends JSON with
9//!   additional null/bool/int/float patterns and octal/hex integer literals.
10//!
11//! The [`Schema`] trait is object-safe; callers may supply a custom
12//! implementation via `&dyn Schema`.
13
14use crate::event::ScalarStyle;
15
16// ---------------------------------------------------------------------------
17// Public types
18// ---------------------------------------------------------------------------
19
20/// A fully resolved scalar value.
21///
22/// Note: `Scalar` intentionally does not implement `Eq` because `f64` has
23/// `NaN != NaN` semantics.  Use `.is_nan()` for NaN comparisons and
24/// `(a - b).abs() < eps` for finite float comparisons.
25#[derive(Debug, Clone, PartialEq)]
26pub enum Scalar {
27    /// A null value.
28    Null,
29    /// A boolean value.
30    Bool(bool),
31    /// An integer value.
32    Int(i64),
33    /// A floating-point value (including ±infinity and NaN).
34    Float(f64),
35    /// A string value.
36    String(String),
37}
38
39/// A resolved YAML tag — the result of expanding a raw tag string.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ResolvedTag {
42    /// `tag:yaml.org,2002:str`
43    Str,
44    /// `tag:yaml.org,2002:int`
45    Int,
46    /// `tag:yaml.org,2002:float`
47    Float,
48    /// `tag:yaml.org,2002:bool`
49    Bool,
50    /// `tag:yaml.org,2002:null`
51    Null,
52    /// `tag:yaml.org,2002:seq`
53    Seq,
54    /// `tag:yaml.org,2002:map`
55    Map,
56    /// Any tag not recognized by the schema.
57    Unknown(String),
58}
59
60// ---------------------------------------------------------------------------
61// Schema trait
62// ---------------------------------------------------------------------------
63
64/// Pluggable schema resolution strategy.
65///
66/// Implementors define how untagged scalars are resolved and how tag strings
67/// are interpreted.  `resolve_scalar` receives the already-expanded tag (if
68/// any); shorthand expansion (`!!str` → `tag:yaml.org,2002:str`) is the
69/// caller's responsibility.
70pub trait Schema {
71    /// Resolve a scalar to a typed value.
72    ///
73    /// - `value` — the scalar text after all YAML escaping has been applied.
74    /// - `tag` — the expanded tag, or `None` if the scalar is untagged.
75    ///   `Some("!")` means the YAML non-specific tag (forces string).
76    ///   `Some("?")` means the untagged marker (schema inference applies).
77    /// - `style` — the presentation style; non-plain styles skip type inference
78    ///   in JSON and Core schemas.
79    fn resolve_scalar(&self, value: &str, tag: Option<&str>, style: ScalarStyle) -> Scalar;
80
81    /// Classify a raw tag string into a [`ResolvedTag`].
82    fn resolve_tag(&self, tag: &str) -> ResolvedTag;
83}
84
85// ---------------------------------------------------------------------------
86// FailsafeSchema
87// ---------------------------------------------------------------------------
88
89/// YAML 1.2 Failsafe schema: all scalars are strings.
90///
91/// Tags are recognized but do not change resolution — every scalar becomes
92/// `Scalar::String`.
93pub struct FailsafeSchema;
94
95impl Schema for FailsafeSchema {
96    fn resolve_scalar(&self, value: &str, _tag: Option<&str>, _style: ScalarStyle) -> Scalar {
97        Scalar::String(value.to_owned())
98    }
99
100    fn resolve_tag(&self, tag: &str) -> ResolvedTag {
101        core_resolve_tag(tag)
102    }
103}
104
105// ---------------------------------------------------------------------------
106// JsonSchema
107// ---------------------------------------------------------------------------
108
109/// YAML 1.2 JSON schema.
110///
111/// Stricter than Core: only lowercase `null`/`true`/`false`, decimal integers
112/// only, no octal/hex, no case variants for infinity/NaN.
113pub struct JsonSchema;
114
115impl Schema for JsonSchema {
116    fn resolve_scalar(&self, value: &str, tag: Option<&str>, style: ScalarStyle) -> Scalar {
117        // Explicit tags override inference.
118        if let Some(resolved) = apply_explicit_tag(tag, value, self) {
119            return resolved;
120        }
121        // Quoted styles always produce strings in JSON schema.
122        if !is_plain(style) {
123            return Scalar::String(value.to_owned());
124        }
125        json_infer(value)
126    }
127
128    fn resolve_tag(&self, tag: &str) -> ResolvedTag {
129        core_resolve_tag(tag)
130    }
131}
132
133// ---------------------------------------------------------------------------
134// CoreSchema
135// ---------------------------------------------------------------------------
136
137/// YAML 1.2 Core schema (default).
138///
139/// Extends JSON with additional null/bool patterns, octal and hex integer
140/// literals, and case-variant infinity/NaN literals.
141pub struct CoreSchema;
142
143impl Schema for CoreSchema {
144    fn resolve_scalar(&self, value: &str, tag: Option<&str>, style: ScalarStyle) -> Scalar {
145        // Explicit tags override inference.
146        if let Some(resolved) = apply_explicit_tag(tag, value, self) {
147            return resolved;
148        }
149        // Quoted/block styles always produce strings in Core schema.
150        if !is_plain(style) {
151            return Scalar::String(value.to_owned());
152        }
153        core_infer(value)
154    }
155
156    fn resolve_tag(&self, tag: &str) -> ResolvedTag {
157        core_resolve_tag(tag)
158    }
159}
160
161// ---------------------------------------------------------------------------
162// Internal helpers
163// ---------------------------------------------------------------------------
164
165/// Returns `true` if the style is plain (unquoted).
166const fn is_plain(style: ScalarStyle) -> bool {
167    matches!(style, ScalarStyle::Plain)
168}
169
170/// Apply an explicit tag override, if the tag is present and recognized.
171///
172/// Returns `None` if the tag should be ignored and inference should proceed.
173fn apply_explicit_tag(tag: Option<&str>, value: &str, schema: &dyn Schema) -> Option<Scalar> {
174    match tag {
175        None | Some("?") => None,
176        Some("!") => Some(Scalar::String(value.to_owned())),
177        Some(t) => match schema.resolve_tag(t) {
178            ResolvedTag::Str | ResolvedTag::Unknown(_) => Some(Scalar::String(value.to_owned())),
179            ResolvedTag::Null => Some(Scalar::Null),
180            ResolvedTag::Bool => Some(core_infer_bool(value).unwrap_or(Scalar::Bool(false))),
181            ResolvedTag::Int => Some(core_infer_int(value).unwrap_or(Scalar::Int(0))),
182            ResolvedTag::Float => Some(core_infer_float(value).unwrap_or(Scalar::Float(0.0))),
183            ResolvedTag::Seq | ResolvedTag::Map => None,
184        },
185    }
186}
187
188/// Resolve a raw tag string to a [`ResolvedTag`].
189fn core_resolve_tag(tag: &str) -> ResolvedTag {
190    match tag {
191        "tag:yaml.org,2002:str" => ResolvedTag::Str,
192        "tag:yaml.org,2002:int" => ResolvedTag::Int,
193        "tag:yaml.org,2002:float" => ResolvedTag::Float,
194        "tag:yaml.org,2002:bool" => ResolvedTag::Bool,
195        "tag:yaml.org,2002:null" => ResolvedTag::Null,
196        "tag:yaml.org,2002:seq" => ResolvedTag::Seq,
197        "tag:yaml.org,2002:map" => ResolvedTag::Map,
198        other => ResolvedTag::Unknown(other.to_owned()),
199    }
200}
201
202/// JSON schema inference for a plain scalar.
203fn json_infer(value: &str) -> Scalar {
204    if value == "null" {
205        return Scalar::Null;
206    }
207    if let Some(b) = json_infer_bool(value) {
208        return b;
209    }
210    if let Some(i) = json_infer_int(value) {
211        return i;
212    }
213    if let Some(f) = json_infer_float(value) {
214        return f;
215    }
216    Scalar::String(value.to_owned())
217}
218
219/// Core schema inference for a plain scalar.
220fn core_infer(value: &str) -> Scalar {
221    if matches!(value, "null" | "Null" | "NULL" | "~" | "") {
222        return Scalar::Null;
223    }
224    if let Some(b) = core_infer_bool(value) {
225        return b;
226    }
227    if let Some(i) = core_infer_int(value) {
228        return i;
229    }
230    if let Some(f) = core_infer_float(value) {
231        return f;
232    }
233    Scalar::String(value.to_owned())
234}
235
236// JSON bool: only `true` / `false` (lowercase).
237fn json_infer_bool(value: &str) -> Option<Scalar> {
238    match value {
239        "true" => Some(Scalar::Bool(true)),
240        "false" => Some(Scalar::Bool(false)),
241        _ => None,
242    }
243}
244
245// Core bool: true/false/True/False/TRUE/FALSE.
246fn core_infer_bool(value: &str) -> Option<Scalar> {
247    match value {
248        "true" | "True" | "TRUE" => Some(Scalar::Bool(true)),
249        "false" | "False" | "FALSE" => Some(Scalar::Bool(false)),
250        _ => None,
251    }
252}
253
254// JSON int: decimal only, optional leading `-`, no leading zeros (except `0`).
255fn json_infer_int(value: &str) -> Option<Scalar> {
256    let digits = value.strip_prefix('-').unwrap_or(value);
257    if digits.is_empty() {
258        return None;
259    }
260    // No leading zeros unless the entire number is "0".
261    if digits.len() > 1 && digits.starts_with('0') {
262        return None;
263    }
264    if !digits.chars().all(|c| c.is_ascii_digit()) {
265        return None;
266    }
267    value.parse::<i64>().ok().map(Scalar::Int)
268}
269
270// Core int: decimal, octal (`0o…`), or hex (`0x…`), optional leading `+`/`-`.
271// Leading zeros in decimal (e.g. `007`) are not valid.
272fn core_infer_int(value: &str) -> Option<Scalar> {
273    let (neg, rest) = value.strip_prefix('-').map_or_else(
274        || (false, value.strip_prefix('+').unwrap_or(value)),
275        |r| (true, r),
276    );
277    if rest.is_empty() {
278        return None;
279    }
280    let magnitude: i64 = if let Some(oct) = rest.strip_prefix("0o") {
281        if oct.is_empty() {
282            return None;
283        }
284        i64::from_str_radix(oct, 8).ok()?
285    } else if let Some(hex) = rest.strip_prefix("0x") {
286        if hex.is_empty() {
287            return None;
288        }
289        i64::from_str_radix(hex, 16).ok()?
290    } else {
291        // Decimal — no leading zeros (unless the value is exactly "0").
292        if rest.len() > 1 && rest.starts_with('0') {
293            return None;
294        }
295        if !rest.chars().all(|c| c.is_ascii_digit()) {
296            return None;
297        }
298        rest.parse::<i64>().ok()?
299    };
300    Some(Scalar::Int(if neg { -magnitude } else { magnitude }))
301}
302
303// JSON float: decimal with `.` or `e`/`E`, plus `.inf`/`-.inf`/`.nan`.
304fn json_infer_float(value: &str) -> Option<Scalar> {
305    match value {
306        ".inf" => return Some(Scalar::Float(f64::INFINITY)),
307        "-.inf" => return Some(Scalar::Float(f64::NEG_INFINITY)),
308        ".nan" => return Some(Scalar::Float(f64::NAN)),
309        _ => {}
310    }
311    let signed = value.strip_prefix('-').unwrap_or(value);
312    if signed.contains('.') || signed.contains('e') || signed.contains('E') {
313        return value.parse::<f64>().ok().map(Scalar::Float);
314    }
315    None
316}
317
318// Core float: extends JSON with case variants of .inf/.nan and `+` prefix.
319fn core_infer_float(value: &str) -> Option<Scalar> {
320    match value {
321        ".inf" | ".Inf" | ".INF" => return Some(Scalar::Float(f64::INFINITY)),
322        "-.inf" | "-.Inf" | "-.INF" => return Some(Scalar::Float(f64::NEG_INFINITY)),
323        ".nan" | ".NaN" | ".NAN" => return Some(Scalar::Float(f64::NAN)),
324        _ => {}
325    }
326    let stripped = value.strip_prefix('+').unwrap_or(value);
327    let signed = stripped.strip_prefix('-').unwrap_or(stripped);
328    if signed.contains('.') || signed.contains('e') || signed.contains('E') {
329        return value
330            .trim_start_matches('+')
331            .parse::<f64>()
332            .ok()
333            .map(Scalar::Float);
334    }
335    None
336}
337
338// ---------------------------------------------------------------------------
339// Tests
340// ---------------------------------------------------------------------------
341
342#[cfg(test)]
343#[allow(
344    clippy::expect_used,
345    clippy::unwrap_used,
346    clippy::doc_markdown,
347    clippy::float_cmp
348)]
349mod tests {
350    use super::*;
351    use crate::event::{Chomp, ScalarStyle};
352
353    // -----------------------------------------------------------------------
354    // Helpers
355    // -----------------------------------------------------------------------
356
357    fn core() -> CoreSchema {
358        CoreSchema
359    }
360    fn json() -> JsonSchema {
361        JsonSchema
362    }
363    fn failsafe() -> FailsafeSchema {
364        FailsafeSchema
365    }
366
367    fn assert_float_eq(actual: &Scalar, expected: f64) {
368        match actual {
369            Scalar::Float(f) => {
370                assert!(
371                    (f - expected).abs() < 1e-10,
372                    "expected Float({expected}), got Float({f})"
373                );
374            }
375            other @ (Scalar::Null | Scalar::Bool(_) | Scalar::Int(_) | Scalar::String(_)) => {
376                panic!("expected Scalar::Float, got {other:?}")
377            }
378        }
379    }
380
381    fn assert_float_inf_pos(actual: &Scalar) {
382        match actual {
383            Scalar::Float(f) => assert!(f.is_infinite() && f.is_sign_positive()),
384            other @ (Scalar::Null | Scalar::Bool(_) | Scalar::Int(_) | Scalar::String(_)) => {
385                panic!("expected positive infinity, got {other:?}")
386            }
387        }
388    }
389
390    fn assert_float_inf_neg(actual: &Scalar) {
391        match actual {
392            Scalar::Float(f) => assert!(f.is_infinite() && f.is_sign_negative()),
393            other @ (Scalar::Null | Scalar::Bool(_) | Scalar::Int(_) | Scalar::String(_)) => {
394                panic!("expected negative infinity, got {other:?}")
395            }
396        }
397    }
398
399    fn assert_float_nan(actual: &Scalar) {
400        match actual {
401            Scalar::Float(f) => assert!(f.is_nan(), "expected NaN, got {f}"),
402            other @ (Scalar::Null | Scalar::Bool(_) | Scalar::Int(_) | Scalar::String(_)) => {
403                panic!("expected NaN float, got {other:?}")
404            }
405        }
406    }
407
408    // -----------------------------------------------------------------------
409    // Group 1: Scalar type — construction and equality
410    // -----------------------------------------------------------------------
411
412    /// Test 1 — spike: CoreSchema resolves "null" to Scalar::Null
413    #[test]
414    fn scalar_null_equals_null() {
415        assert_eq!(Scalar::Null, Scalar::Null);
416        // Spike: confirm the whole pipeline works.
417        assert_eq!(
418            core().resolve_scalar("null", None, ScalarStyle::Plain),
419            Scalar::Null
420        );
421    }
422
423    /// Test 2 — Bool(true) equals Bool(true)
424    #[test]
425    fn scalar_bool_true_equals_bool_true() {
426        assert_eq!(Scalar::Bool(true), Scalar::Bool(true));
427    }
428
429    /// Test 3 — Bool(true) does not equal Bool(false)
430    #[test]
431    fn scalar_bool_true_does_not_equal_false() {
432        assert_ne!(Scalar::Bool(true), Scalar::Bool(false));
433    }
434
435    /// Test 4 — Int equality
436    #[test]
437    fn scalar_int_equality() {
438        assert_eq!(Scalar::Int(42), Scalar::Int(42));
439    }
440
441    /// Test 5 — String equality
442    #[test]
443    fn scalar_string_equality() {
444        assert_eq!(
445            Scalar::String("hello".to_owned()),
446            Scalar::String("hello".to_owned())
447        );
448    }
449
450    // -----------------------------------------------------------------------
451    // Group 2: FailsafeSchema — all scalars are strings
452    // -----------------------------------------------------------------------
453
454    /// Test 6 — failsafe plain "null" is String
455    #[test]
456    fn failsafe_plain_null_is_string() {
457        assert_eq!(
458            failsafe().resolve_scalar("null", None, ScalarStyle::Plain),
459            Scalar::String("null".to_owned())
460        );
461    }
462
463    /// Test 7 — failsafe plain "true" is String
464    #[test]
465    fn failsafe_plain_true_is_string() {
466        assert_eq!(
467            failsafe().resolve_scalar("true", None, ScalarStyle::Plain),
468            Scalar::String("true".to_owned())
469        );
470    }
471
472    /// Test 8 — failsafe plain "42" is String
473    #[test]
474    fn failsafe_plain_integer_is_string() {
475        assert_eq!(
476            failsafe().resolve_scalar("42", None, ScalarStyle::Plain),
477            Scalar::String("42".to_owned())
478        );
479    }
480
481    /// Test 9 — failsafe single-quoted "hello" is String
482    #[test]
483    fn failsafe_quoted_value_is_string() {
484        assert_eq!(
485            failsafe().resolve_scalar("hello", None, ScalarStyle::SingleQuoted),
486            Scalar::String("hello".to_owned())
487        );
488    }
489
490    /// Test 10 — failsafe ignores explicit int tag; still String
491    #[test]
492    fn failsafe_explicit_tag_ignored_still_string() {
493        assert_eq!(
494            failsafe().resolve_scalar("42", Some("tag:yaml.org,2002:int"), ScalarStyle::Plain),
495            Scalar::String("42".to_owned())
496        );
497    }
498
499    // -----------------------------------------------------------------------
500    // Group 3: CoreSchema — null resolution
501    // -----------------------------------------------------------------------
502
503    /// Test 11 — plain "null" (lowercase) is Null
504    #[test]
505    fn core_plain_null_lowercase_is_null() {
506        assert_eq!(
507            core().resolve_scalar("null", None, ScalarStyle::Plain),
508            Scalar::Null
509        );
510    }
511
512    /// Test 12 — plain "Null" (titlecase) is Null
513    #[test]
514    fn core_plain_null_titlecase_is_null() {
515        assert_eq!(
516            core().resolve_scalar("Null", None, ScalarStyle::Plain),
517            Scalar::Null
518        );
519    }
520
521    /// Test 13 — plain "NULL" (uppercase) is Null
522    #[test]
523    fn core_plain_null_uppercase_is_null() {
524        assert_eq!(
525            core().resolve_scalar("NULL", None, ScalarStyle::Plain),
526            Scalar::Null
527        );
528    }
529
530    /// Test 14 — plain "~" is Null
531    #[test]
532    fn core_plain_tilde_is_null() {
533        assert_eq!(
534            core().resolve_scalar("~", None, ScalarStyle::Plain),
535            Scalar::Null
536        );
537    }
538
539    /// Test 15 — plain empty scalar is Null
540    #[test]
541    fn core_empty_plain_scalar_is_null() {
542        assert_eq!(
543            core().resolve_scalar("", None, ScalarStyle::Plain),
544            Scalar::Null
545        );
546    }
547
548    /// Test 16 — single-quoted "null" is String (quoted bypasses inference)
549    #[test]
550    fn core_quoted_null_is_string() {
551        assert_eq!(
552            core().resolve_scalar("null", None, ScalarStyle::SingleQuoted),
553            Scalar::String("null".to_owned())
554        );
555    }
556
557    /// Test 17 — double-quoted empty is String, not Null
558    #[test]
559    fn core_quoted_empty_is_string() {
560        assert_eq!(
561            core().resolve_scalar("", None, ScalarStyle::DoubleQuoted),
562            Scalar::String(String::new())
563        );
564    }
565
566    // -----------------------------------------------------------------------
567    // Group 4: CoreSchema — bool resolution
568    // -----------------------------------------------------------------------
569
570    /// Test 18 — plain "true" (lowercase) is Bool(true)
571    #[test]
572    fn core_plain_true_lowercase_is_bool_true() {
573        assert_eq!(
574            core().resolve_scalar("true", None, ScalarStyle::Plain),
575            Scalar::Bool(true)
576        );
577    }
578
579    /// Test 19 — plain "false" (lowercase) is Bool(false)
580    #[test]
581    fn core_plain_false_lowercase_is_bool_false() {
582        assert_eq!(
583            core().resolve_scalar("false", None, ScalarStyle::Plain),
584            Scalar::Bool(false)
585        );
586    }
587
588    /// Test 20 — plain "True" (titlecase) is Bool(true)
589    #[test]
590    fn core_plain_true_titlecase_is_bool_true() {
591        assert_eq!(
592            core().resolve_scalar("True", None, ScalarStyle::Plain),
593            Scalar::Bool(true)
594        );
595    }
596
597    /// Test 21 — plain "False" (titlecase) is Bool(false)
598    #[test]
599    fn core_plain_false_titlecase_is_bool_false() {
600        assert_eq!(
601            core().resolve_scalar("False", None, ScalarStyle::Plain),
602            Scalar::Bool(false)
603        );
604    }
605
606    /// Test 22 — plain "TRUE" (uppercase) is Bool(true)
607    #[test]
608    fn core_plain_true_uppercase_is_bool_true() {
609        assert_eq!(
610            core().resolve_scalar("TRUE", None, ScalarStyle::Plain),
611            Scalar::Bool(true)
612        );
613    }
614
615    /// Test 23 — plain "FALSE" (uppercase) is Bool(false)
616    #[test]
617    fn core_plain_false_uppercase_is_bool_false() {
618        assert_eq!(
619            core().resolve_scalar("FALSE", None, ScalarStyle::Plain),
620            Scalar::Bool(false)
621        );
622    }
623
624    /// Test 24 — single-quoted "true" is String
625    #[test]
626    fn core_quoted_true_is_string() {
627        assert_eq!(
628            core().resolve_scalar("true", None, ScalarStyle::SingleQuoted),
629            Scalar::String("true".to_owned())
630        );
631    }
632
633    /// Test 25 — double-quoted "false" is String
634    #[test]
635    fn core_quoted_false_is_string() {
636        assert_eq!(
637            core().resolve_scalar("false", None, ScalarStyle::DoubleQuoted),
638            Scalar::String("false".to_owned())
639        );
640    }
641
642    /// Test 26 — plain "yes" is String (YAML 1.2 Core does not recognize yes/no)
643    #[test]
644    fn core_plain_yes_is_string() {
645        assert_eq!(
646            core().resolve_scalar("yes", None, ScalarStyle::Plain),
647            Scalar::String("yes".to_owned())
648        );
649    }
650
651    /// Test 27 — plain "on" is String (YAML 1.2 Core does not recognize on/off)
652    #[test]
653    fn core_plain_on_is_string() {
654        assert_eq!(
655            core().resolve_scalar("on", None, ScalarStyle::Plain),
656            Scalar::String("on".to_owned())
657        );
658    }
659
660    // -----------------------------------------------------------------------
661    // Group 5: CoreSchema — integer resolution
662    // -----------------------------------------------------------------------
663
664    /// Test 28 — plain "0" is Int(0)
665    #[test]
666    fn core_plain_decimal_zero_is_int() {
667        assert_eq!(
668            core().resolve_scalar("0", None, ScalarStyle::Plain),
669            Scalar::Int(0)
670        );
671    }
672
673    /// Test 29 — plain "42" is Int(42)
674    #[test]
675    fn core_plain_positive_decimal_is_int() {
676        assert_eq!(
677            core().resolve_scalar("42", None, ScalarStyle::Plain),
678            Scalar::Int(42)
679        );
680    }
681
682    /// Test 30 — plain "-17" is Int(-17)
683    #[test]
684    fn core_plain_negative_decimal_is_int() {
685        assert_eq!(
686            core().resolve_scalar("-17", None, ScalarStyle::Plain),
687            Scalar::Int(-17)
688        );
689    }
690
691    /// Test 31 — plain "+5" is Int(5)
692    #[test]
693    fn core_plain_explicit_plus_decimal_is_int() {
694        assert_eq!(
695            core().resolve_scalar("+5", None, ScalarStyle::Plain),
696            Scalar::Int(5)
697        );
698    }
699
700    /// Test 32 — plain "0o777" is Int(511)
701    #[test]
702    fn core_plain_octal_is_int() {
703        assert_eq!(
704            core().resolve_scalar("0o777", None, ScalarStyle::Plain),
705            Scalar::Int(0o777)
706        );
707    }
708
709    /// Test 33 — plain "0o0" is Int(0)
710    #[test]
711    fn core_plain_octal_zero_is_int() {
712        assert_eq!(
713            core().resolve_scalar("0o0", None, ScalarStyle::Plain),
714            Scalar::Int(0)
715        );
716    }
717
718    /// Test 34 — plain "-0o7" is Int(-7)
719    #[test]
720    fn core_plain_negative_octal_is_int() {
721        assert_eq!(
722            core().resolve_scalar("-0o7", None, ScalarStyle::Plain),
723            Scalar::Int(-7)
724        );
725    }
726
727    /// Test 35 — plain "0xFF" is Int(255)
728    #[test]
729    fn core_plain_hex_is_int() {
730        assert_eq!(
731            core().resolve_scalar("0xFF", None, ScalarStyle::Plain),
732            Scalar::Int(255)
733        );
734    }
735
736    /// Test 36 — plain "0xff" is Int(255)
737    #[test]
738    fn core_plain_hex_lowercase_is_int() {
739        assert_eq!(
740            core().resolve_scalar("0xff", None, ScalarStyle::Plain),
741            Scalar::Int(255)
742        );
743    }
744
745    /// Test 37 — plain "-0x10" is Int(-16)
746    #[test]
747    fn core_plain_negative_hex_is_int() {
748        assert_eq!(
749            core().resolve_scalar("-0x10", None, ScalarStyle::Plain),
750            Scalar::Int(-16)
751        );
752    }
753
754    /// Test 38 — plain "007" is String (leading zeros are not octal in YAML 1.2)
755    #[test]
756    fn core_plain_leading_zero_decimal_is_string() {
757        assert_eq!(
758            core().resolve_scalar("007", None, ScalarStyle::Plain),
759            Scalar::String("007".to_owned())
760        );
761    }
762
763    // -----------------------------------------------------------------------
764    // Group 6: CoreSchema — float resolution
765    // -----------------------------------------------------------------------
766
767    /// Test 39 — plain "1.25" is Float(1.25)
768    #[test]
769    fn core_plain_decimal_float_is_float() {
770        let result = core().resolve_scalar("1.25", None, ScalarStyle::Plain);
771        assert_float_eq(&result, 1.25_f64);
772    }
773
774    /// Test 40 — plain "-1.5" is Float(-1.5)
775    #[test]
776    fn core_plain_negative_float_is_float() {
777        let result = core().resolve_scalar("-1.5", None, ScalarStyle::Plain);
778        assert_float_eq(&result, -1.5_f64);
779    }
780
781    /// Test 41 — plain "+0.5" is Float(0.5)
782    #[test]
783    fn core_plain_positive_float_is_float() {
784        let result = core().resolve_scalar("+0.5", None, ScalarStyle::Plain);
785        assert_float_eq(&result, 0.5_f64);
786    }
787
788    /// Test 42 — plain "1.0e10" is Float(1.0e10)
789    #[test]
790    fn core_plain_float_scientific_lowercase_e_is_float() {
791        let result = core().resolve_scalar("1.0e10", None, ScalarStyle::Plain);
792        assert_float_eq(&result, 1.0e10_f64);
793    }
794
795    /// Test 43 — plain "2.5E3" is Float(2500.0)
796    #[test]
797    fn core_plain_float_scientific_uppercase_e_is_float() {
798        let result = core().resolve_scalar("2.5E3", None, ScalarStyle::Plain);
799        assert_float_eq(&result, 2500.0_f64);
800    }
801
802    /// Test 44 — plain ".inf" is positive infinity
803    #[test]
804    fn core_plain_dot_inf_lowercase_is_positive_infinity() {
805        let result = core().resolve_scalar(".inf", None, ScalarStyle::Plain);
806        assert_float_inf_pos(&result);
807    }
808
809    /// Test 45 — plain ".Inf" is positive infinity
810    #[test]
811    fn core_plain_dot_inf_titlecase_is_positive_infinity() {
812        let result = core().resolve_scalar(".Inf", None, ScalarStyle::Plain);
813        assert_float_inf_pos(&result);
814    }
815
816    /// Test 46 — plain ".INF" is positive infinity
817    #[test]
818    fn core_plain_dot_inf_uppercase_is_positive_infinity() {
819        let result = core().resolve_scalar(".INF", None, ScalarStyle::Plain);
820        assert_float_inf_pos(&result);
821    }
822
823    /// Test 47 — plain "-.inf" is negative infinity
824    #[test]
825    fn core_plain_negative_dot_inf_lowercase_is_negative_infinity() {
826        let result = core().resolve_scalar("-.inf", None, ScalarStyle::Plain);
827        assert_float_inf_neg(&result);
828    }
829
830    /// Test 48 — plain "-.Inf" is negative infinity
831    #[test]
832    fn core_plain_negative_dot_inf_titlecase_is_negative_infinity() {
833        let result = core().resolve_scalar("-.Inf", None, ScalarStyle::Plain);
834        assert_float_inf_neg(&result);
835    }
836
837    /// Test 49 — plain "-.INF" is negative infinity
838    #[test]
839    fn core_plain_negative_dot_inf_uppercase_is_negative_infinity() {
840        let result = core().resolve_scalar("-.INF", None, ScalarStyle::Plain);
841        assert_float_inf_neg(&result);
842    }
843
844    /// Test 50 — plain ".nan" is NaN
845    #[test]
846    fn core_plain_dot_nan_lowercase_is_nan() {
847        let result = core().resolve_scalar(".nan", None, ScalarStyle::Plain);
848        assert_float_nan(&result);
849    }
850
851    /// Test 51 — plain ".NaN" is NaN
852    #[test]
853    fn core_plain_dot_nan_titlecase_is_nan() {
854        let result = core().resolve_scalar(".NaN", None, ScalarStyle::Plain);
855        assert_float_nan(&result);
856    }
857
858    /// Test 52 — plain ".NAN" is NaN
859    #[test]
860    fn core_plain_dot_nan_uppercase_is_nan() {
861        let result = core().resolve_scalar(".NAN", None, ScalarStyle::Plain);
862        assert_float_nan(&result);
863    }
864
865    // -----------------------------------------------------------------------
866    // Group 7: CoreSchema — string fallback
867    // -----------------------------------------------------------------------
868
869    /// Test 53 — plain "hello" is String
870    #[test]
871    fn core_plain_arbitrary_word_is_string() {
872        assert_eq!(
873            core().resolve_scalar("hello", None, ScalarStyle::Plain),
874            Scalar::String("hello".to_owned())
875        );
876    }
877
878    /// Test 54 — plain "42abc" is String
879    #[test]
880    fn core_plain_integer_looking_with_letters_is_string() {
881        assert_eq!(
882            core().resolve_scalar("42abc", None, ScalarStyle::Plain),
883            Scalar::String("42abc".to_owned())
884        );
885    }
886
887    /// Test 55 — plain "0o" (bare octal prefix, no digits) is String
888    #[test]
889    fn core_plain_partial_octal_prefix_is_string() {
890        assert_eq!(
891            core().resolve_scalar("0o", None, ScalarStyle::Plain),
892            Scalar::String("0o".to_owned())
893        );
894    }
895
896    /// Test 56 — plain "0x" (bare hex prefix, no digits) is String
897    #[test]
898    fn core_plain_partial_hex_prefix_is_string() {
899        assert_eq!(
900            core().resolve_scalar("0x", None, ScalarStyle::Plain),
901            Scalar::String("0x".to_owned())
902        );
903    }
904
905    /// Test 57 — literal block style is String (bypasses inference)
906    #[test]
907    fn core_literal_block_scalar_is_string() {
908        assert_eq!(
909            core().resolve_scalar("hello\n", None, ScalarStyle::Literal(Chomp::Clip)),
910            Scalar::String("hello\n".to_owned())
911        );
912    }
913
914    // -----------------------------------------------------------------------
915    // Group 8: Explicit tag override
916    // -----------------------------------------------------------------------
917
918    /// Test 58 — !!str tag on integer-looking plain forces String
919    #[test]
920    fn core_str_tag_on_integer_looking_plain_is_string() {
921        assert_eq!(
922            core().resolve_scalar("123", Some("tag:yaml.org,2002:str"), ScalarStyle::Plain),
923            Scalar::String("123".to_owned())
924        );
925    }
926
927    /// Test 59 — !!str tag on "null"-looking plain forces String
928    #[test]
929    fn core_str_tag_on_null_looking_plain_is_string() {
930        assert_eq!(
931            core().resolve_scalar("null", Some("tag:yaml.org,2002:str"), ScalarStyle::Plain),
932            Scalar::String("null".to_owned())
933        );
934    }
935
936    /// Test 60 — !!str tag on "true"-looking plain forces String
937    #[test]
938    fn core_str_tag_on_bool_looking_plain_is_string() {
939        assert_eq!(
940            core().resolve_scalar("true", Some("tag:yaml.org,2002:str"), ScalarStyle::Plain),
941            Scalar::String("true".to_owned())
942        );
943    }
944
945    /// Test 61 — !!int tag on decimal plain forces Int
946    #[test]
947    fn core_int_tag_on_decimal_is_int() {
948        assert_eq!(
949            core().resolve_scalar("42", Some("tag:yaml.org,2002:int"), ScalarStyle::Plain),
950            Scalar::Int(42)
951        );
952    }
953
954    /// Test 62 — !!float tag on decimal plain forces Float
955    #[test]
956    fn core_float_tag_on_decimal_is_float() {
957        let result =
958            core().resolve_scalar("1.25", Some("tag:yaml.org,2002:float"), ScalarStyle::Plain);
959        assert_float_eq(&result, 1.25_f64);
960    }
961
962    /// Test 63 — !!null tag forces Null regardless of value
963    #[test]
964    fn core_null_tag_on_string_value_is_null() {
965        assert_eq!(
966            core().resolve_scalar(
967                "anything",
968                Some("tag:yaml.org,2002:null"),
969                ScalarStyle::Plain
970            ),
971            Scalar::Null
972        );
973    }
974
975    /// Test 64 — !!bool tag on "true" is Bool(true)
976    #[test]
977    fn core_bool_tag_on_true_is_bool() {
978        assert_eq!(
979            core().resolve_scalar("true", Some("tag:yaml.org,2002:bool"), ScalarStyle::Plain),
980            Scalar::Bool(true)
981        );
982    }
983
984    /// Test 65 — !!bool tag on "false" is Bool(false)
985    #[test]
986    fn core_bool_tag_on_false_is_bool() {
987        assert_eq!(
988            core().resolve_scalar("false", Some("tag:yaml.org,2002:bool"), ScalarStyle::Plain),
989            Scalar::Bool(false)
990        );
991    }
992
993    /// Test 66 — !!str shorthand (expanded to tag:yaml.org,2002:str) forces String
994    ///
995    /// Tag shorthand expansion (!!str → tag:yaml.org,2002:str) is the caller's
996    /// responsibility; resolve_scalar receives the already-expanded form.
997    #[test]
998    fn core_bang_str_shorthand_tag_is_string() {
999        assert_eq!(
1000            core().resolve_scalar("123", Some("tag:yaml.org,2002:str"), ScalarStyle::Plain),
1001            Scalar::String("123".to_owned())
1002        );
1003    }
1004
1005    /// Test 67 — verbatim !<tag:yaml.org,2002:str> (expanded form) forces String
1006    #[test]
1007    fn core_verbatim_str_tag_is_string() {
1008        assert_eq!(
1009            core().resolve_scalar("42", Some("tag:yaml.org,2002:str"), ScalarStyle::Plain),
1010            Scalar::String("42".to_owned())
1011        );
1012    }
1013
1014    /// Test 68 — unknown tag falls back to String (no panic)
1015    #[test]
1016    fn core_unknown_tag_falls_back_to_string() {
1017        assert_eq!(
1018            core().resolve_scalar("hello", Some("tag:example.com:custom"), ScalarStyle::Plain),
1019            Scalar::String("hello".to_owned())
1020        );
1021    }
1022
1023    // -----------------------------------------------------------------------
1024    // Group 9: Non-specific tags `!` and `?`
1025    // -----------------------------------------------------------------------
1026
1027    /// Test 69 — `!` tag forces String
1028    #[test]
1029    fn core_bang_tag_forces_string() {
1030        assert_eq!(
1031            core().resolve_scalar("null", Some("!"), ScalarStyle::Plain),
1032            Scalar::String("null".to_owned())
1033        );
1034    }
1035
1036    /// Test 70 — `?` tag uses schema inference (null)
1037    #[test]
1038    fn core_question_tag_uses_schema_inference() {
1039        assert_eq!(
1040            core().resolve_scalar("null", Some("?"), ScalarStyle::Plain),
1041            Scalar::Null
1042        );
1043    }
1044
1045    /// Test 71 — `?` tag on plain "42" uses inference (Int)
1046    #[test]
1047    fn core_question_tag_on_plain_int_uses_inference() {
1048        assert_eq!(
1049            core().resolve_scalar("42", Some("?"), ScalarStyle::Plain),
1050            Scalar::Int(42)
1051        );
1052    }
1053
1054    /// Test 72 — `?` tag on single-quoted "42" is String (quoted bypasses inference)
1055    #[test]
1056    fn core_question_tag_on_quoted_int_is_string() {
1057        assert_eq!(
1058            core().resolve_scalar("42", Some("?"), ScalarStyle::SingleQuoted),
1059            Scalar::String("42".to_owned())
1060        );
1061    }
1062
1063    // -----------------------------------------------------------------------
1064    // Group 10: JsonSchema
1065    // -----------------------------------------------------------------------
1066
1067    /// Test 73 — json: only lowercase "null" is Null
1068    #[test]
1069    fn json_plain_null_only_lowercase_is_null() {
1070        assert_eq!(
1071            json().resolve_scalar("null", None, ScalarStyle::Plain),
1072            Scalar::Null
1073        );
1074    }
1075
1076    /// Test 74 — json: "Null" (titlecase) is String
1077    #[test]
1078    fn json_plain_null_titlecase_is_string() {
1079        assert_eq!(
1080            json().resolve_scalar("Null", None, ScalarStyle::Plain),
1081            Scalar::String("Null".to_owned())
1082        );
1083    }
1084
1085    /// Test 75 — json: "~" is String (not null in JSON schema)
1086    #[test]
1087    fn json_plain_tilde_is_string() {
1088        assert_eq!(
1089            json().resolve_scalar("~", None, ScalarStyle::Plain),
1090            Scalar::String("~".to_owned())
1091        );
1092    }
1093
1094    /// Test 76 — json: empty plain is String (not null in JSON schema)
1095    #[test]
1096    fn json_plain_empty_is_string() {
1097        assert_eq!(
1098            json().resolve_scalar("", None, ScalarStyle::Plain),
1099            Scalar::String(String::new())
1100        );
1101    }
1102
1103    /// Test 77 — json: "true" is Bool(true)
1104    #[test]
1105    fn json_plain_true_lowercase_is_bool_true() {
1106        assert_eq!(
1107            json().resolve_scalar("true", None, ScalarStyle::Plain),
1108            Scalar::Bool(true)
1109        );
1110    }
1111
1112    /// Test 78 — json: "false" is Bool(false)
1113    #[test]
1114    fn json_plain_false_lowercase_is_bool_false() {
1115        assert_eq!(
1116            json().resolve_scalar("false", None, ScalarStyle::Plain),
1117            Scalar::Bool(false)
1118        );
1119    }
1120
1121    /// Test 79 — json: "True" (titlecase) is String
1122    #[test]
1123    fn json_plain_true_titlecase_is_string() {
1124        assert_eq!(
1125            json().resolve_scalar("True", None, ScalarStyle::Plain),
1126            Scalar::String("True".to_owned())
1127        );
1128    }
1129
1130    /// Test 80 — json: "42" is Int(42)
1131    #[test]
1132    fn json_plain_decimal_int_is_int() {
1133        assert_eq!(
1134            json().resolve_scalar("42", None, ScalarStyle::Plain),
1135            Scalar::Int(42)
1136        );
1137    }
1138
1139    /// Test 81 — json: "-5" is Int(-5)
1140    #[test]
1141    fn json_plain_negative_decimal_int_is_int() {
1142        assert_eq!(
1143            json().resolve_scalar("-5", None, ScalarStyle::Plain),
1144            Scalar::Int(-5)
1145        );
1146    }
1147
1148    /// Test 82 — json: "0o77" is String (no octal in JSON schema)
1149    #[test]
1150    fn json_plain_octal_is_string() {
1151        assert_eq!(
1152            json().resolve_scalar("0o77", None, ScalarStyle::Plain),
1153            Scalar::String("0o77".to_owned())
1154        );
1155    }
1156
1157    /// Test 83 — json: "0xFF" is String (no hex in JSON schema)
1158    #[test]
1159    fn json_plain_hex_is_string() {
1160        assert_eq!(
1161            json().resolve_scalar("0xFF", None, ScalarStyle::Plain),
1162            Scalar::String("0xFF".to_owned())
1163        );
1164    }
1165
1166    /// Test 84 — json: "1.5" is Float(1.5)
1167    #[test]
1168    fn json_plain_decimal_float_is_float() {
1169        let result = json().resolve_scalar("1.5", None, ScalarStyle::Plain);
1170        assert_float_eq(&result, 1.5_f64);
1171    }
1172
1173    /// Test 85 — json: ".inf" is positive infinity
1174    #[test]
1175    fn json_plain_dot_inf_is_float_infinity() {
1176        let result = json().resolve_scalar(".inf", None, ScalarStyle::Plain);
1177        assert_float_inf_pos(&result);
1178    }
1179
1180    /// Test 86 — json: ".nan" is NaN
1181    #[test]
1182    fn json_plain_dot_nan_is_float_nan() {
1183        let result = json().resolve_scalar(".nan", None, ScalarStyle::Plain);
1184        assert_float_nan(&result);
1185    }
1186
1187    /// Test 87 — json: double-quoted "null" is String
1188    #[test]
1189    fn json_quoted_null_is_string() {
1190        assert_eq!(
1191            json().resolve_scalar("null", None, ScalarStyle::DoubleQuoted),
1192            Scalar::String("null".to_owned())
1193        );
1194    }
1195
1196    // -----------------------------------------------------------------------
1197    // Group 11: Schema trait — pluggable custom schema
1198    // -----------------------------------------------------------------------
1199
1200    /// Test 88 — Schema is object-safe and callable via &dyn Schema
1201    #[test]
1202    fn custom_schema_via_trait_object_is_callable() {
1203        struct AlwaysNull;
1204        impl Schema for AlwaysNull {
1205            fn resolve_scalar(
1206                &self,
1207                _value: &str,
1208                _tag: Option<&str>,
1209                _style: ScalarStyle,
1210            ) -> Scalar {
1211                Scalar::Null
1212            }
1213            fn resolve_tag(&self, tag: &str) -> ResolvedTag {
1214                ResolvedTag::Unknown(tag.to_owned())
1215            }
1216        }
1217        let schema: &dyn Schema = &AlwaysNull;
1218        assert_eq!(
1219            schema.resolve_scalar("hello", None, ScalarStyle::Plain),
1220            Scalar::Null
1221        );
1222    }
1223
1224    /// Test 89 — custom schema overrides core behavior
1225    #[test]
1226    fn custom_schema_overrides_core_behavior() {
1227        struct StringOnly;
1228        impl Schema for StringOnly {
1229            fn resolve_scalar(
1230                &self,
1231                value: &str,
1232                _tag: Option<&str>,
1233                _style: ScalarStyle,
1234            ) -> Scalar {
1235                Scalar::String(value.to_owned())
1236            }
1237            fn resolve_tag(&self, tag: &str) -> ResolvedTag {
1238                ResolvedTag::Unknown(tag.to_owned())
1239            }
1240        }
1241        assert_eq!(
1242            StringOnly.resolve_scalar("42", None, ScalarStyle::Plain),
1243            Scalar::String("42".to_owned())
1244        );
1245    }
1246
1247    /// Test 90 — resolve_tag on CoreSchema returns known tag for str
1248    #[test]
1249    fn resolve_tag_on_core_schema_returns_known_tags() {
1250        assert_eq!(
1251            core().resolve_tag("tag:yaml.org,2002:str"),
1252            ResolvedTag::Str
1253        );
1254    }
1255
1256    /// Test 91 — resolve_tag on CoreSchema returns Unknown for foreign tag
1257    #[test]
1258    fn resolve_tag_on_core_schema_returns_unknown_for_foreign_tag() {
1259        assert!(matches!(
1260            core().resolve_tag("tag:example.com:custom"),
1261            ResolvedTag::Unknown(_)
1262        ));
1263    }
1264
1265    // -----------------------------------------------------------------------
1266    // Group 12: Integration — schema module accessible from crate root
1267    // -----------------------------------------------------------------------
1268
1269    /// Test 92 — schema module is accessible from crate
1270    #[test]
1271    fn schema_module_is_accessible_from_crate() {
1272        let result = crate::schema::CoreSchema.resolve_scalar("null", None, ScalarStyle::Plain);
1273        assert_eq!(result, Scalar::Null);
1274    }
1275
1276    /// Test 93 — Scalar is accessible from crate
1277    #[test]
1278    fn schema_scalar_is_accessible_from_crate() {
1279        let s = crate::schema::Scalar::Null;
1280        assert_ne!(s, crate::schema::Scalar::Bool(false));
1281    }
1282
1283    /// Test 94 — CoreSchema resolves "42" to Int(42) (default schema behavior)
1284    #[test]
1285    fn core_schema_is_default_schema() {
1286        // CoreSchema is a unit struct; Default is derived.
1287        assert_eq!(
1288            CoreSchema.resolve_scalar("42", None, ScalarStyle::Plain),
1289            Scalar::Int(42)
1290        );
1291    }
1292
1293    /// Test 95 — FailsafeSchema is accessible from crate
1294    #[test]
1295    fn failsafe_schema_is_accessible_from_crate() {
1296        assert_eq!(
1297            crate::schema::FailsafeSchema.resolve_scalar("null", None, ScalarStyle::Plain),
1298            Scalar::String("null".to_owned())
1299        );
1300    }
1301
1302    /// Test 96 — JsonSchema is accessible from crate
1303    #[test]
1304    fn json_schema_is_accessible_from_crate() {
1305        assert_eq!(
1306            crate::schema::JsonSchema.resolve_scalar("true", None, ScalarStyle::Plain),
1307            Scalar::Bool(true)
1308        );
1309    }
1310}