Skip to main content

yaml_schema/schemas/
yaml_schema.rs

1use std::collections::HashSet;
2use std::fmt::Display;
3use std::rc::Rc;
4
5use hashlink::LinkedHashMap;
6use jsonptr::Token;
7use log::debug;
8use log::error;
9use saphyr::{AnnotatedMapping, MarkedYaml, Scalar, YamlData};
10
11use crate::ConstValue;
12use crate::Context;
13use crate::Error;
14use crate::RefUri;
15use crate::Reference;
16use crate::Result;
17use crate::Validator;
18use crate::loader::load_boolean_or_schema_marked;
19use crate::loader::load_external_schema;
20use crate::loader::marked_yaml_mapping_key_to_string;
21use crate::loader::marked_yaml_to_string;
22use crate::schemas::AllOfSchema;
23use crate::schemas::AnyOfSchema;
24use crate::schemas::ArraySchema;
25use crate::schemas::EnumSchema;
26use crate::schemas::IfThenElseSchema;
27use crate::schemas::IntegerSchema;
28use crate::schemas::NotSchema;
29use crate::schemas::NumberSchema;
30use crate::schemas::ObjectSchema;
31use crate::schemas::OneOfSchema;
32use crate::schemas::StringSchema;
33use crate::utils::format_annotated_mapping;
34use crate::utils::format_linked_hash_map;
35use crate::utils::format_marked_yaml;
36use crate::utils::format_marker;
37use crate::utils::format_scalar;
38use crate::utils::format_vec;
39use crate::utils::format_yaml_data;
40use crate::utils::scalar_to_string;
41use crate::validation::ArrayUnevaluatedAnnotations;
42
43/// YamlSchema is the base of the validation model
44#[derive(Debug, PartialEq)]
45pub enum YamlSchema {
46    Empty,                // no value
47    Null,                 // `null`
48    BooleanLiteral(bool), // `true` or `false`
49    Subschema(Box<Subschema>),
50}
51
52impl YamlSchema {
53    pub fn subschema(subschema: Subschema) -> Self {
54        Self::Subschema(Box::new(subschema))
55    }
56
57    pub fn ref_str(ref_name: impl Into<String>) -> Self {
58        Self::subschema(Subschema {
59            r#ref: Some(Reference::new(ref_name)),
60            ..Default::default()
61        })
62    }
63
64    /// Create a YamlSchema with a single type: `boolean`
65    pub fn typed_boolean() -> Self {
66        Self::subschema(Subschema {
67            r#type: SchemaType::new("boolean"),
68            ..Default::default()
69        })
70    }
71
72    /// Create a YamlSchema with a single type: `number`
73    pub fn typed_number(number_schema: NumberSchema) -> Self {
74        number_schema.into()
75    }
76
77    /// Create a YamlSchema with a single type: `string`
78    pub fn typed_string(string_schema: StringSchema) -> Self {
79        Self::subschema(Subschema {
80            r#type: SchemaType::new("string"),
81            string_schema: Some(string_schema),
82            ..Default::default()
83        })
84    }
85
86    /// Create a YamlSchema with a single type: `object`
87    pub fn typed_object(object_schema: ObjectSchema) -> Self {
88        Self::subschema(Subschema {
89            r#type: SchemaType::new("object"),
90            object_schema: Some(object_schema),
91            ..Default::default()
92        })
93    }
94
95    /// Resolve a portion of a JSON Pointer to an element in the schema.
96    pub fn resolve(
97        &self,
98        key: Option<&Token>,
99        components: &[jsonptr::Component],
100    ) -> Option<&YamlSchema> {
101        debug!("[YamlSchema#resolve] self: {self}, key: {key:?}, components: {components:?}");
102        if components.is_empty() {
103            return Some(self);
104        }
105        match self {
106            YamlSchema::Subschema(subschema) => subschema.resolve(key, components),
107            _ => None,
108        }
109    }
110}
111
112impl<'r> TryFrom<&MarkedYaml<'r>> for YamlSchema {
113    type Error = crate::Error;
114    fn try_from(marked_yaml: &MarkedYaml<'r>) -> crate::Result<Self> {
115        match &marked_yaml.data {
116            YamlData::Value(scalar) => match scalar {
117                Scalar::Boolean(value) => Ok(YamlSchema::BooleanLiteral(*value)),
118                Scalar::Null => Ok(YamlSchema::Null),
119                _ => Err(generic_error!(
120                    "[YamlSchema#try_from] Expected a boolean or null, but got: {}",
121                    format_scalar(scalar)
122                )),
123            },
124            YamlData::Mapping(_) => Subschema::try_from(marked_yaml).map(YamlSchema::subschema),
125            _ => Err(generic_error!(
126                "[YamlSchema#try_from] Expected a boolean, null, or a mapping, but got: {}",
127                format_marked_yaml(marked_yaml)
128            )),
129        }
130    }
131}
132
133impl From<NumberSchema> for YamlSchema {
134    fn from(number_schema: NumberSchema) -> Self {
135        YamlSchema::subschema(Subschema {
136            r#type: SchemaType::new("number"),
137            number_schema: Some(number_schema),
138            ..Default::default()
139        })
140    }
141}
142
143impl From<IntegerSchema> for YamlSchema {
144    fn from(integer_schema: IntegerSchema) -> Self {
145        YamlSchema::subschema(Subschema {
146            r#type: SchemaType::new("integer"),
147            integer_schema: Some(integer_schema),
148            ..Default::default()
149        })
150    }
151}
152
153impl From<StringSchema> for YamlSchema {
154    fn from(string_schema: StringSchema) -> Self {
155        YamlSchema::subschema(Subschema {
156            r#type: SchemaType::new("string"),
157            string_schema: Some(string_schema),
158            ..Default::default()
159        })
160    }
161}
162
163impl Validator for YamlSchema {
164    fn validate(&self, context: &Context, value: &saphyr::MarkedYaml) -> Result<()> {
165        debug!("[YamlSchema] self: {self}");
166        debug!(
167            "[YamlSchema] Validating value: {}",
168            format_yaml_data(&value.data)
169        );
170        match self {
171            YamlSchema::Empty => Ok(()),
172            YamlSchema::Null => {
173                if !matches!(&value.data, YamlData::Value(Scalar::Null)) {
174                    context.add_error(
175                        value,
176                        format!("Expected null, but got: {}", format_yaml_data(&value.data)),
177                    );
178                }
179                Ok(())
180            }
181            YamlSchema::BooleanLiteral(boolean) => {
182                if !*boolean {
183                    context.add_error(value, "YamlSchema is `false`!");
184                }
185                Ok(())
186            }
187            YamlSchema::Subschema(subschema) => {
188                debug!("[YamlSchema#validate] Validating subschema: {subschema:?}");
189                subschema.validate(context, value)?;
190                Ok(())
191            }
192        }
193    }
194}
195
196impl From<Subschema> for YamlSchema {
197    fn from(subschema: Subschema) -> Self {
198        YamlSchema::subschema(subschema)
199    }
200}
201
202impl Display for YamlSchema {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        match self {
205            YamlSchema::Empty => write!(f, "<empty>"),
206            YamlSchema::Null => write!(f, "null"),
207            YamlSchema::BooleanLiteral(value) => write!(f, "{value}"),
208            YamlSchema::Subschema(subschema) => subschema.fmt(f),
209        }
210    }
211}
212
213/// Represents either a literal boolean value or a YamlSchema
214#[derive(Debug, PartialEq)]
215pub enum BooleanOrSchema {
216    Boolean(bool),
217    Schema(YamlSchema),
218}
219
220impl BooleanOrSchema {
221    pub fn schema(schema: YamlSchema) -> Self {
222        BooleanOrSchema::Schema(schema)
223    }
224}
225
226impl Display for BooleanOrSchema {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        match self {
229            BooleanOrSchema::Boolean(value) => write!(f, "{value}"),
230            BooleanOrSchema::Schema(schema) => schema.fmt(f),
231        }
232    }
233}
234
235#[derive(Debug, Default, PartialEq)]
236pub enum SchemaType {
237    #[default]
238    /// No `type:` was provided
239    None,
240    /// A single type
241    Single(String),
242    /// Multiple types
243    Multiple(Vec<String>),
244}
245
246impl SchemaType {
247    /// Create a new SchemaType with a single value
248    pub fn new<S: Into<String>>(value: S) -> Self {
249        SchemaType::Single(value.into())
250    }
251
252    pub fn is_none(&self) -> bool {
253        matches!(self, SchemaType::None)
254    }
255
256    pub fn is_single(&self) -> bool {
257        matches!(self, SchemaType::Single(_))
258    }
259
260    pub fn is_multiple(&self) -> bool {
261        matches!(self, SchemaType::Multiple(_))
262    }
263
264    /// Checks if this SchemaType matches or contains the given type string.
265    ///
266    /// Returns `true` if:
267    /// - The schema type is `Single` and matches the given type string, or
268    /// - The schema type is `Multiple` and contains the given type string
269    ///
270    /// Returns `false` if:
271    /// - The schema type is `None`, or
272    /// - The schema type doesn't match/contain the given type string
273    ///
274    /// # Examples
275    ///
276    /// ```
277    /// use yaml_schema::schemas::SchemaType;
278    ///
279    /// // Test with a single type
280    /// let single = SchemaType::new("string");
281    /// assert!(single.is_or_contains("string"));
282    /// assert!(!single.is_or_contains("number"));
283    ///
284    /// // Test with multiple types
285    /// let multiple = SchemaType::Multiple(vec!["string".to_string(), "number".to_string()]);
286    /// assert!(multiple.is_or_contains("string"));
287    /// assert!(multiple.is_or_contains("number"));
288    /// assert!(!multiple.is_or_contains("boolean"));
289    ///
290    /// // Test with None (no type specified)
291    /// let none = SchemaType::None;
292    /// assert!(!none.is_or_contains("string"));
293    /// ```
294    pub fn is_or_contains(&self, r#type: &str) -> bool {
295        match self {
296            SchemaType::None => false,
297            SchemaType::Single(s) => s == r#type,
298            SchemaType::Multiple(values) => values.contains(&r#type.to_string()),
299        }
300    }
301
302    /// Checks if this SchemaType is `None` or `Single("string")`. Used to determine if `propertyNames`
303    /// validates the canonical string form of each key.
304    pub fn is_none_or_string(&self) -> bool {
305        match self {
306            SchemaType::None => true,
307            SchemaType::Single(s) => s == "string",
308            SchemaType::Multiple(_) => false,
309        }
310    }
311}
312
313impl Display for SchemaType {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        match self {
316            SchemaType::None => Ok(()), // No `type:` was provided
317            SchemaType::Single(value) => write!(f, "{value}"),
318            SchemaType::Multiple(values) => write!(f, "{}", format_vec(values)),
319        }
320    }
321}
322
323/// A Subschema contains the core schema elements and validation
324#[derive(Debug, Default, PartialEq)]
325pub struct Subschema {
326    /// `$id` and `$schema` metadata and `title` and `description` annotations
327    pub metadata_and_annotations: MetadataAndAnnotations,
328    /// `$anchor` metadata
329    pub anchor: Option<String>,
330    /// `$ref`
331    pub r#ref: Option<Reference>,
332    /// `$defs`
333    pub defs: Option<LinkedHashMap<String, YamlSchema>>,
334    /// `anyOf`
335    pub any_of: Option<AnyOfSchema>,
336    /// `allOf`
337    pub all_of: Option<AllOfSchema>,
338    /// `oneOf`
339    pub one_of: Option<OneOfSchema>,
340    /// `not`
341    pub not: Option<NotSchema>,
342    /// `if` / `then` / `else`
343    pub if_then_else: Option<IfThenElseSchema>,
344    /// `type`
345    pub r#type: SchemaType,
346    /// `const`
347    pub r#const: Option<ConstValue>,
348    /// `enum`
349    pub r#enum: Option<EnumSchema>,
350
351    pub array_schema: Option<ArraySchema>,
352    pub integer_schema: Option<IntegerSchema>,
353    pub number_schema: Option<NumberSchema>,
354    pub object_schema: Option<ObjectSchema>,
355    pub string_schema: Option<StringSchema>,
356    /// `unevaluatedProperties` (JSON Schema 2020-12 unevaluated vocabulary).
357    pub unevaluated_properties: Option<BooleanOrSchema>,
358    /// `unevaluatedItems`.
359    pub unevaluated_items: Option<BooleanOrSchema>,
360}
361
362impl Subschema {
363    /// Resolve a portion of a JSON Pointer to an element in the schema.
364    pub fn resolve(
365        &self,
366        token: Option<&Token>,
367        components: &[jsonptr::Component],
368    ) -> Option<&YamlSchema> {
369        debug!("[Subschema#resolve] self: {self}, token: {token:?}, components: {components:?}");
370        if let Some(token) = token {
371            let s = token.decoded();
372            debug!("[Subschema#resolve] key: {s}");
373            match s.as_ref() {
374                "$defs" => {
375                    debug!("[Subschema#resolve] Resolving $defs");
376                    if let Some(defs) = self.defs.as_ref() {
377                        debug!("[Subschema#resolve] defs: {}", format_linked_hash_map(defs));
378                        if let Some(component) = components.first() {
379                            debug!("[Subschema#resolve] component: {component:?}");
380                            if let jsonptr::Component::Token(next_token) = component {
381                                let decoded = next_token.decoded();
382                                debug!("[Subschema#resolve] decoded: {decoded}");
383                                debug!("[Subschema#resolve] defs: {defs:?}");
384                                if let Some(schema) = defs.get(decoded.as_ref()) {
385                                    debug!("[Subschema#resolve] schema: {schema:?}");
386                                    return schema.resolve(Some(next_token), &components[1..]);
387                                }
388                            }
389                        }
390                    }
391                }
392                "anyOf" => {}
393                _ => (),
394            }
395        }
396        None
397    }
398}
399
400// Try to load a Subschema from a MarkedYaml. Delegate to the TryFrom<&AnnotatedMapping<'_>> for mappings.
401// If the MarkedYaml is not a mapping, returns an error.
402impl<'r> TryFrom<&MarkedYaml<'r>> for Subschema {
403    type Error = crate::Error;
404    fn try_from(marked_yaml: &MarkedYaml<'r>) -> crate::Result<Self> {
405        if let YamlData::Mapping(mapping) = &marked_yaml.data {
406            Self::try_from(mapping)
407        } else {
408            Err(generic_error!(
409                "{} Expected a mapping, but got: {:?}",
410                format_marker(&marked_yaml.span.start),
411                marked_yaml
412            ))
413        }
414    }
415}
416
417fn try_load_defs<'r>(marked_yaml: &MarkedYaml<'r>) -> Result<LinkedHashMap<String, YamlSchema>> {
418    debug!(
419        "[try_load_defs] marked_yaml: {}",
420        format_yaml_data(&marked_yaml.data)
421    );
422    if let YamlData::Mapping(mapping) = &marked_yaml.data {
423        debug!(
424            "[try_load_defs] mapping: {}",
425            format_annotated_mapping(mapping)
426        );
427        mapping
428            .iter()
429            .try_fold(LinkedHashMap::new(), |mut acc, (key, value)| {
430                let key = marked_yaml_mapping_key_to_string(key)?;
431                acc.insert(key, value.try_into()?);
432                Ok(acc)
433            })
434    } else {
435        Err(expected_mapping!(marked_yaml))
436    }
437}
438
439impl<'r> TryFrom<&AnnotatedMapping<'r, MarkedYaml<'r>>> for Subschema {
440    type Error = Error;
441
442    fn try_from(mapping: &AnnotatedMapping<'r, MarkedYaml<'r>>) -> crate::Result<Self> {
443        debug!(
444            "[Subschema#try_from] mapping has {} keys",
445            mapping.keys().len()
446        );
447        for key in mapping.keys() {
448            debug!("[Subschema#try_from] key: {:?}", key.data);
449        }
450
451        let metadata_and_annotations = MetadataAndAnnotations::try_from(mapping)?;
452        debug!("[Subschema#try_from] metadata_and_annotations: {metadata_and_annotations}");
453
454        // $defs
455        let defs: Option<LinkedHashMap<String, YamlSchema>> = mapping
456            .get(&MarkedYaml::value_from_str("$defs"))
457            .map(|x| {
458                debug!("[Subschema#try_from] x: {}", format_yaml_data(&x.data));
459                debug!("[Subschema#try_from] Trying to load `$defs` as LinkedHashMap<String, YamlSchema>");
460                try_load_defs(x)
461            })
462            .transpose()?;
463
464        // $ref
465        let reference: Option<Reference> = mapping
466            .get(&MarkedYaml::value_from_str("$ref"))
467            .map(|_| {
468                debug!("[Subschema#try_from] Trying to load `$ref` as Reference");
469                mapping.try_into()
470            })
471            .transpose()?;
472
473        // anyOf
474        let any_of: Option<AnyOfSchema> = mapping
475            .get(&MarkedYaml::value_from_str("anyOf"))
476            .map(|_| {
477                debug!("[Subschema#try_from] Trying to load `anyOf` as AnyOfSchema");
478                mapping.try_into()
479            })
480            .transpose()?;
481
482        // allOf
483        let all_of: Option<AllOfSchema> = mapping
484            .get(&MarkedYaml::value_from_str("allOf"))
485            .map(|_| {
486                debug!("[Subschema#try_from] Trying to load `allOf` as AllOfSchema");
487                mapping.try_into()
488            })
489            .transpose()?;
490
491        // oneOf
492        let one_of: Option<OneOfSchema> = mapping
493            .get(&MarkedYaml::value_from_str("oneOf"))
494            .map(|_| {
495                debug!("[Subschema#try_from] Trying to load `oneOf` as OneOfSchema");
496                mapping.try_into()
497            })
498            .transpose()?;
499
500        // not
501        let not: Option<NotSchema> = mapping
502            .get(&MarkedYaml::value_from_str("not"))
503            .map(|_| {
504                debug!("[Subschema#try_from] Trying to load `not` as NotSchema");
505                mapping.try_into()
506            })
507            .transpose()?;
508
509        // if / then / else (only when `if` is present)
510        let if_then_else: Option<IfThenElseSchema> = mapping
511            .get(&MarkedYaml::value_from_str("if"))
512            .map(|_| {
513                debug!(
514                    "[Subschema#try_from] Trying to load `if`/`then`/`else` as IfThenElseSchema"
515                );
516                IfThenElseSchema::try_from(mapping)
517            })
518            .transpose()?;
519
520        // const
521        let mut r#const: Option<ConstValue> = None;
522        if let Some(value) = mapping.get(&MarkedYaml::value_from_str("const")) {
523            r#const = Some(ConstValue::try_from(value)?);
524        }
525
526        // enum
527        let mut r#enum: Option<EnumSchema> = None;
528        if let Some(value) = mapping.get(&MarkedYaml::value_from_str("enum")) {
529            r#enum = Some(value.try_into()?);
530        }
531
532        // type
533        let mut r#type: SchemaType = SchemaType::None;
534        if let Some(type_value) = mapping.get(&MarkedYaml::value_from_str("type")) {
535            match &type_value.data {
536                YamlData::Value(Scalar::Null) => {
537                    r#type = SchemaType::new("null");
538                }
539                YamlData::Value(Scalar::String(s)) => r#type = SchemaType::new(s.as_ref()),
540                YamlData::Sequence(values) => {
541                    r#type = SchemaType::Multiple(
542                        values
543                            .iter()
544                            .map(|marked_yaml| {
545                                marked_yaml_to_string(marked_yaml, "type must be a string")
546                            })
547                            .collect::<Result<Vec<String>>>()?,
548                    )
549                }
550                _ => {
551                    return Err(schema_loading_error!(
552                        "[Subschema#try_from] Expected a string or sequence for `type`, but got: {:?}",
553                        type_value.data
554                    ));
555                }
556            }
557        }
558
559        // Instantiate the appropriate schema based on the type(s)
560        let mut array_schema = None;
561        let mut integer_schema = None;
562        let mut number_schema = None;
563        let mut object_schema = None;
564        let mut string_schema = None;
565
566        let types: Vec<&str> = match r#type {
567            SchemaType::None => vec![],
568            SchemaType::Single(ref s) => vec![s],
569            SchemaType::Multiple(ref values) => values.iter().map(|s| s.as_ref()).collect(),
570        };
571
572        for s in types {
573            match s {
574                "array" => {
575                    debug!("[Subschema#try_from] Instantiating array schema");
576                    array_schema = ArraySchema::try_from(mapping).map(Some)?;
577                }
578                // No subschema needed for boolean, we handle it in the validate_by_type method
579                "boolean" => {}
580                "integer" => {
581                    debug!("[Subschema#try_from] Instantiating integer schema");
582                    integer_schema = IntegerSchema::try_from(mapping).map(Some)?;
583                }
584                "number" => {
585                    debug!("[Subschema#try_from] Instantiating number schema");
586                    number_schema = NumberSchema::try_from(mapping).map(Some)?;
587                }
588                "object" => {
589                    debug!("[Subschema#try_from] Instantiating object schema");
590                    object_schema = ObjectSchema::try_from(mapping).map(Some)?;
591                }
592                "string" => {
593                    debug!("[Subschema#try_from] Instantiating string schema");
594                    string_schema = StringSchema::try_from(mapping).map(Some)?;
595                }
596                "null" => (),
597                _ => {
598                    return Err(unsupported_type!(
599                        "Expected type: string, number, integer, object, array, boolean, or null, but got: {}",
600                        s
601                    ));
602                }
603            }
604        }
605
606        if r#type.is_none() {
607            // When `type` is omitted but `properties` is present, try to load as `type: object` (JSON Schema-style).
608            if mapping.contains_key(&MarkedYaml::value_from_str("properties")) {
609                // r#type = SchemaType::new("object");
610                object_schema = ObjectSchema::try_from(mapping).map(Some)?;
611            }
612
613            // When `type` is omitted but string validation keywords are present, try to load as `type: string`
614            // so `pattern` / `minLength` / `maxLength` are not ignored (JSON Schema-style).
615            if mapping.contains_key(&MarkedYaml::value_from_str("pattern"))
616                || mapping.contains_key(&MarkedYaml::value_from_str("minLength"))
617                || mapping.contains_key(&MarkedYaml::value_from_str("maxLength"))
618            {
619                r#type = SchemaType::new("string");
620                string_schema = StringSchema::try_from(mapping).map(Some)?;
621            }
622        }
623
624        let unevaluated_properties = mapping
625            .get(&MarkedYaml::value_from_str("unevaluatedProperties"))
626            .map(load_boolean_or_schema_marked)
627            .transpose()?;
628        let unevaluated_items = mapping
629            .get(&MarkedYaml::value_from_str("unevaluatedItems"))
630            .map(load_boolean_or_schema_marked)
631            .transpose()?;
632
633        debug!("[Subschema#try_from] array_schema: {array_schema:?}");
634        debug!("[Subschema#try_from] integer_schema: {integer_schema:?}");
635        debug!("[Subschema#try_from] number_schema: {number_schema:?}");
636        debug!("[Subschema#try_from] object_schema: {object_schema:?}");
637        debug!("[Subschema#try_from] string_schema: {string_schema:?}");
638
639        Ok(Self {
640            metadata_and_annotations,
641            defs,
642            r#ref: reference,
643            any_of,
644            all_of,
645            one_of,
646            not,
647            if_then_else,
648            r#type,
649            r#const,
650            r#enum,
651            array_schema,
652            integer_schema,
653            number_schema,
654            object_schema,
655            string_schema,
656            unevaluated_properties,
657            unevaluated_items,
658            anchor: None,
659        })
660    }
661}
662
663impl Display for Subschema {
664    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
665        write!(f, "{{")?;
666        if !self.metadata_and_annotations.is_empty() {
667            write!(f, " ")?;
668            self.metadata_and_annotations.fmt(f)?;
669            write!(f, " ")?;
670        }
671        if !self.r#type.is_none() {
672            write!(f, "type: ")?;
673            self.r#type.fmt(f)?;
674        }
675        if let Some(r#ref) = &self.r#ref {
676            write!(f, "$ref: ")?;
677            r#ref.fmt(f)?;
678        }
679        if let Some(defs) = &self.defs {
680            write!(f, "$defs: {}", format_linked_hash_map(defs))?;
681        }
682        if let Some(any_of) = &self.any_of {
683            write!(f, "anyOf: ")?;
684            any_of.fmt(f)?;
685        }
686        if let Some(all_of) = &self.all_of {
687            write!(f, "allOf: ")?;
688            all_of.fmt(f)?;
689        }
690        if let Some(one_of) = &self.one_of {
691            write!(f, "oneOf: ")?;
692            one_of.fmt(f)?;
693        }
694        if let Some(not) = &self.not {
695            write!(f, "not: ")?;
696            not.fmt(f)?;
697        }
698        if let Some(ite) = &self.if_then_else {
699            write!(f, "if/then/else: {ite}")?;
700        }
701        write!(f, "}}")?;
702        Ok(())
703    }
704}
705
706impl Validator for Subschema {
707    fn validate(&self, context: &Context, value: &saphyr::MarkedYaml) -> crate::Result<()> {
708        debug!("[Subschema] self: {self}");
709        debug!(
710            "[Subschema] Validating value: {}",
711            format_yaml_data(&value.data)
712        );
713
714        if let Some(reference) = &self.r#ref {
715            debug!("[Subschema] Reference found: {reference}");
716            let ref_name = &reference.ref_name;
717            if let Some(root_schema) = context.root_schema {
718                if let Some(ref_path) = ref_name.strip_prefix("#") {
719                    if context.is_resolving_ref(ref_name, value) {
720                        context.add_error(value, format!("Circular $ref detected: {ref_name}"));
721                        return Ok(());
722                    }
723                    let pointer = jsonptr::Pointer::parse(ref_path)?;
724                    debug!("[Subschema] Pointer: {pointer}");
725                    let schema = root_schema.resolve(pointer);
726                    if let Some(schema) = schema {
727                        debug!("[Subschema] Found {ref_path}: {schema}");
728                        context.begin_resolving_ref(ref_name, value);
729                        let result = schema.validate(context, value);
730                        context.end_resolving_ref(ref_name, value);
731                        result?;
732                    } else {
733                        error!("[Subschema] Cannot find definition: {ref_path}");
734                        context.add_error(value, format!("Schema {ref_path} not found"));
735                    }
736                } else {
737                    // External ref: resolve, load schema if needed, resolve fragment
738                    let ref_uri = RefUri::parse(ref_name);
739                    let resolved_url = if ref_uri.is_absolute() {
740                        let mut url = url::Url::parse(ref_uri.base_ref()).map_err(|e| {
741                            generic_error!("Failed to parse absolute $ref URI {}: {}", ref_name, e)
742                        })?;
743                        if let Some(frag) = ref_uri.fragment() {
744                            url.set_fragment(Some(frag));
745                        }
746                        url
747                    } else {
748                        let base = root_schema.base_uri.as_ref().ok_or_else(|| {
749                            generic_error!(
750                                "Relative $ref requires schema to be loaded from a file or URL. Found: {}",
751                                ref_name
752                            )
753                        })?;
754                        ref_uri.resolve_against(base)?
755                    };
756                    let ref_key = resolved_url.to_string();
757                    if context.is_resolving_ref(&ref_key, value) {
758                        context.add_error(value, format!("Circular $ref detected: {ref_name}"));
759                        return Ok(());
760                    }
761                    let doc_url = {
762                        let mut u = resolved_url.clone();
763                        u.set_fragment(None);
764                        u.to_string()
765                    };
766                    let fragment = resolved_url.fragment().and_then(|f| {
767                        let s = if f.starts_with('/') {
768                            f.to_string()
769                        } else {
770                            format!("/{f}")
771                        };
772                        if s.is_empty() || s == "/" {
773                            None
774                        } else {
775                            Some(s)
776                        }
777                    });
778                    {
779                        let mut schemas = context.schemas.borrow_mut();
780                        if !schemas.contains_key(&doc_url) {
781                            let loaded = load_external_schema(&doc_url)?;
782                            let schema_rc = Rc::new(loaded);
783                            let key = schema_rc.cache_key(&doc_url);
784                            schemas.insert(key.clone(), Rc::clone(&schema_rc));
785                            if key != doc_url {
786                                schemas.insert(doc_url.clone(), schema_rc);
787                            }
788                        }
789                    }
790                    let schemas = context.schemas.borrow();
791                    let schema = schemas.get(&doc_url).ok_or_else(|| {
792                        generic_error!("Schema {doc_url} not in cache after load")
793                    })?;
794                    let pointer_opt = fragment
795                        .as_ref()
796                        .map(|frag| jsonptr::Pointer::parse(frag))
797                        .transpose()?;
798                    let target = match &pointer_opt {
799                        Some(pointer) => schema.resolve(pointer),
800                        None => Some(&schema.schema),
801                    };
802                    if let Some(target) = target {
803                        context.begin_resolving_ref(&ref_key, value);
804                        let result = target.validate(context, value);
805                        context.end_resolving_ref(&ref_key, value);
806                        result?;
807                    } else {
808                        error!("[Subschema] Cannot find definition: {:?}", fragment);
809                        context.add_error(
810                            value,
811                            format!("Schema {:?} not found in {doc_url}", fragment),
812                        );
813                    }
814                }
815                return Ok(());
816            } else {
817                return Err(generic_error!(
818                    "Subschema has a reference, but no root schema was provided!"
819                ));
820            }
821        }
822
823        // `unevaluated*` on the same mapping as `$ref` are not applied when `$ref` is present
824        // (validation returns above). See gap #1 / `$ref` sibling behavior.
825        let ctx = Self::validation_context_for_instance(context, value);
826
827        if let Some(any_of) = &self.any_of {
828            debug!("[Subschema] Validating anyOf schema: {any_of:?}");
829            any_of.validate(&ctx, value)?;
830        }
831
832        if let Some(all_of) = &self.all_of {
833            debug!("[Subschema] Validating allOf schema: {all_of:?}");
834            all_of.validate(&ctx, value)?;
835        }
836
837        if let Some(one_of) = &self.one_of {
838            debug!("[Subschema] Validating oneOf schema: {one_of:?}");
839            one_of.validate(&ctx, value)?;
840        }
841
842        if let Some(not) = &self.not {
843            debug!("[Subschema] Validating not schema: {not:?}");
844            not.validate(&ctx, value)?;
845        }
846
847        if let Some(if_then_else) = &self.if_then_else {
848            debug!("[Subschema] Validating if/then/else: {if_then_else:?}");
849            if_then_else.validate(&ctx, value)?;
850        }
851
852        match &self.r#type {
853            SchemaType::None => {
854                if self
855                    .object_schema
856                    .as_ref()
857                    .is_some_and(|object_schema| object_schema.properties.is_some())
858                {
859                    self.validate_by_type(&ctx, "object", value)?;
860                }
861            }
862            SchemaType::Single(s) => self.validate_by_type(&ctx, s.as_ref(), value)?,
863            SchemaType::Multiple(values) => {
864                debug!(
865                    "[Subschema] Validating multiple types: {}",
866                    values.join(", ")
867                );
868                let mut any_matched = false;
869                for s in values {
870                    let sub_context = ctx.get_sub_context();
871                    self.validate_by_type(&sub_context, s.as_ref(), value)?;
872                    if !sub_context.has_errors() {
873                        any_matched = true;
874                        break;
875                    }
876                }
877                if !any_matched {
878                    ctx.add_error(
879                        value,
880                        format!("None of type: [{}] matched", values.join(", ")),
881                    );
882                }
883            }
884        }
885
886        if let Some(r#const) = &self.r#const
887            && !r#const.accepts(value)
888        {
889            ctx.add_error(
890                value,
891                format!(
892                    "Expected const: {:#?}, but got: {}",
893                    r#const,
894                    format_yaml_data(&value.data)
895                ),
896            );
897        }
898
899        if let Some(r#enum) = &self.r#enum {
900            debug!("[Subschema] Validating enum schema: {}", r#enum);
901            r#enum.validate(&ctx, value)?;
902        }
903
904        self.apply_unevaluated(&ctx, value)?;
905
906        Ok(())
907    }
908}
909
910impl Subschema {
911    fn validation_context_for_instance<'r>(base: &Context<'r>, value: &MarkedYaml) -> Context<'r> {
912        match &value.data {
913            YamlData::Mapping(_) => {
914                let oe = base.object_evaluated.clone().unwrap_or_default();
915                base.with_object_evaluated(Some(oe))
916            }
917            YamlData::Sequence(_) => {
918                let arr = base
919                    .array_unevaluated
920                    .clone()
921                    .unwrap_or_else(ArrayUnevaluatedAnnotations::new_shared);
922                base.with_array_unevaluated(Some(arr))
923            }
924            _ => base
925                .with_object_evaluated(base.object_evaluated.clone())
926                .with_array_unevaluated(base.array_unevaluated.clone()),
927        }
928    }
929
930    fn apply_unevaluated(&self, ctx: &Context, value: &MarkedYaml) -> Result<()> {
931        if let YamlData::Mapping(mapping) = &value.data
932            && let Some(u) = &self.unevaluated_properties
933        {
934            let evaluated: HashSet<String> = ctx
935                .object_evaluated
936                .as_ref()
937                .map(|o| o.snapshot())
938                .unwrap_or_default();
939            for (k, v) in mapping.iter() {
940                let key_string = match &k.data {
941                    YamlData::Value(scalar) => scalar_to_string(scalar),
942                    _ => {
943                        return Err(expected_scalar!(
944                            "[{}] Expected a scalar object key, got: {:?}",
945                            format_marker(&k.span.start),
946                            k.data
947                        ));
948                    }
949                };
950                if key_string == "$schema" {
951                    continue;
952                }
953                if evaluated.contains(&key_string) {
954                    continue;
955                }
956                let prop_ctx = ctx.append_path(&key_string);
957                match u {
958                    BooleanOrSchema::Boolean(false) => {
959                        ctx.add_error(
960                            v,
961                            format!("Unevaluated property '{key_string}' is not allowed!"),
962                        );
963                    }
964                    BooleanOrSchema::Boolean(true) => {}
965                    BooleanOrSchema::Schema(s) => {
966                        s.validate(&prop_ctx, v)?;
967                    }
968                }
969            }
970        }
971
972        if let YamlData::Sequence(seq) = &value.data
973            && let Some(u) = &self.unevaluated_items
974        {
975            let ann = ctx
976                .array_unevaluated
977                .as_ref()
978                .map(|c| c.borrow().clone())
979                .unwrap_or_default();
980            if ann.full_coverage {
981                return Ok(());
982            }
983            let indices = ann.indices_requiring_unevaluated(seq.len());
984            let err_before = ctx.errors.borrow().len();
985            for i in indices.iter().copied() {
986                let item = &seq[i];
987                let item_ctx = ctx.append_path(i.to_string());
988                match u {
989                    BooleanOrSchema::Boolean(false) => {
990                        ctx.add_error(
991                            item,
992                            format!("Unevaluated array item at index {i} is not allowed!"),
993                        );
994                    }
995                    BooleanOrSchema::Boolean(true) => {}
996                    BooleanOrSchema::Schema(s) => {
997                        s.validate(&item_ctx, item)?;
998                    }
999                }
1000            }
1001            if ctx.errors.borrow().len() == err_before
1002                && !indices.is_empty()
1003                && let Some(cell) = &ctx.array_unevaluated
1004            {
1005                let mut a = cell.borrow_mut();
1006                a.saw_relevant = true;
1007                a.full_coverage = true;
1008            }
1009        }
1010
1011        Ok(())
1012    }
1013
1014    fn validate_by_type(
1015        &self,
1016        context: &Context,
1017        r#type: &str,
1018        value: &saphyr::MarkedYaml,
1019    ) -> Result<()> {
1020        debug!("[Subschema#validate_by_type] r#type: {}", r#type);
1021        match r#type {
1022            "array" => {
1023                if let Some(array_schema) = &self.array_schema {
1024                    debug!("[Subschema] Validating array schema: {array_schema:?}");
1025                    array_schema.validate(context, value)?;
1026                } else {
1027                    error!("[Subschema#validate_by_type] No array schema found");
1028                    context.add_error(value, format!("No array schema found for type: {}", r#type));
1029                }
1030            }
1031            "boolean" => {
1032                if !matches!(&value.data, YamlData::Value(Scalar::Boolean(_))) {
1033                    context.add_error(
1034                        value,
1035                        format!(
1036                            "Expected boolean, but got: {}",
1037                            format_yaml_data(&value.data)
1038                        ),
1039                    );
1040                }
1041            }
1042            "null" => {
1043                if !matches!(&value.data, YamlData::Value(Scalar::Null)) {
1044                    context.add_error(
1045                        value,
1046                        format!("Expected null, but got: {}", format_yaml_data(&value.data)),
1047                    );
1048                }
1049            }
1050            "string" => {
1051                if let Some(string_schema) = &self.string_schema {
1052                    debug!("[Subschema] Validating string schema: {string_schema:?}");
1053                    string_schema.validate(context, value)?;
1054                } else {
1055                    error!("[Subschema#validate_by_type] No string schema found");
1056                    context.add_error(
1057                        value,
1058                        format!("No string schema found for type: {}", r#type),
1059                    );
1060                }
1061            }
1062            "number" => {
1063                if let Some(number_schema) = &self.number_schema {
1064                    debug!("[Subschema] Validating number schema: {number_schema:?}");
1065                    number_schema.validate(context, value)?;
1066                } else {
1067                    error!("[Subschema#validate_by_type] No number schema found");
1068                    context.add_error(
1069                        value,
1070                        format!("No number schema found for type: {}", r#type),
1071                    );
1072                }
1073            }
1074            "integer" => {
1075                if let Some(integer_schema) = &self.integer_schema {
1076                    debug!("[Subschema] Validating integer schema: {integer_schema:?}");
1077                    integer_schema.validate(context, value)?;
1078                } else {
1079                    error!("[Subschema#validate_by_type] No integer schema found");
1080                    context.add_error(
1081                        value,
1082                        format!("No integer schema found for type: {}", r#type),
1083                    );
1084                }
1085            }
1086            "object" => {
1087                if let Some(object_schema) = &self.object_schema {
1088                    debug!("[Subschema] Validating object schema: {object_schema:?}");
1089                    object_schema.validate(context, value)?;
1090                } else {
1091                    error!("[Subschema#validate_by_type] No object schema found");
1092                    context.add_error(
1093                        value,
1094                        format!("No object schema found for type: {}", r#type),
1095                    );
1096                }
1097            }
1098            _ => {
1099                error!("[Subschema#validate_by_type] Unsupported type: {}", r#type);
1100                context.add_error(value, format!("Unsupported type: {}", r#type));
1101            }
1102        }
1103        Ok(())
1104    }
1105}
1106
1107/// The `$id` and `$schema` metadata
1108#[derive(Debug, Default, PartialEq)]
1109pub struct MetadataAndAnnotations {
1110    /// `$id` metadata
1111    pub id: Option<String>,
1112    /// `$schema` metadata
1113    pub schema: Option<String>,
1114    /// `title` annotation
1115    pub title: Option<String>,
1116    /// `description` annotation
1117    pub description: Option<String>,
1118}
1119
1120impl MetadataAndAnnotations {
1121    pub fn is_empty(&self) -> bool {
1122        self.id.is_none()
1123            && self.schema.is_none()
1124            && self.title.is_none()
1125            && self.description.is_none()
1126    }
1127}
1128
1129impl std::fmt::Display for MetadataAndAnnotations {
1130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1131        write!(f, "{{")?;
1132        if !self.is_empty() {
1133            write!(f, " ")?;
1134            if let Some(id) = &self.id {
1135                write!(f, "id: {id}, ")?;
1136            }
1137            if let Some(schema) = &self.schema {
1138                write!(f, "schema: {schema}, ")?;
1139            }
1140            if let Some(title) = &self.title {
1141                write!(f, "title: {title}, ")?;
1142            }
1143            if let Some(description) = &self.description {
1144                write!(f, "description: {description}, ")?;
1145            }
1146            write!(f, " ")?;
1147        }
1148        write!(f, "}}")?;
1149        Ok(())
1150    }
1151}
1152
1153impl TryFrom<&AnnotatedMapping<'_, MarkedYaml<'_>>> for MetadataAndAnnotations {
1154    type Error = Error;
1155
1156    fn try_from(mapping: &AnnotatedMapping<'_, MarkedYaml<'_>>) -> crate::Result<Self> {
1157        let mut metadata_and_annotations = MetadataAndAnnotations::default();
1158        for (key, value) in mapping.iter() {
1159            match &key.data {
1160                YamlData::Value(Scalar::String(s)) => match s.as_ref() {
1161                    "$id" => {
1162                        metadata_and_annotations.id =
1163                            Some(marked_yaml_to_string(value, "$id must be a string")?);
1164                    }
1165                    "$schema" => {
1166                        metadata_and_annotations.schema =
1167                            Some(marked_yaml_to_string(value, "$schema must be a string")?);
1168                    }
1169                    "title" => {
1170                        metadata_and_annotations.title =
1171                            Some(marked_yaml_to_string(value, "title must be a string")?);
1172                    }
1173                    "description" => {
1174                        metadata_and_annotations.description = Some(marked_yaml_to_string(
1175                            value,
1176                            "description must be a string",
1177                        )?);
1178                    }
1179                    _ => {
1180                        debug!("[MetadataAndAnnotations#try_from] Unknown key: {s}");
1181                    }
1182                },
1183                _ => {
1184                    debug!("[MetadataAndAnnotations#try_from] Unsupported key data: {key:?}");
1185                }
1186            }
1187        }
1188        Ok(metadata_and_annotations)
1189    }
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use saphyr::LoadableYamlNode;
1195
1196    use crate::engine;
1197    use crate::loader;
1198
1199    use super::*;
1200
1201    #[test]
1202    fn test_type_boolean() {
1203        let yaml = r#"
1204        type: boolean
1205        "#;
1206        let doc = MarkedYaml::load_from_str(yaml).expect("Failed to load YAML");
1207        let marked_yaml = doc.first().unwrap();
1208        let yaml_schema = YamlSchema::try_from(marked_yaml).unwrap();
1209        let YamlSchema::Subschema(subschema) = yaml_schema else {
1210            panic!("Expected a subschema");
1211        };
1212        assert!(!subschema.r#type.is_none());
1213        assert!(subschema.r#type.is_single());
1214        let SchemaType::Single(type_value) = subschema.r#type else {
1215            panic!("Expected a single type");
1216        };
1217        assert_eq!(type_value, "boolean");
1218    }
1219
1220    #[test]
1221    fn test_metadata_and_annotations_try_from() {
1222        let yaml = r#"
1223        $id: http://example.com/schema
1224        $schema: http://example.com/schema
1225        title: Example Schema
1226        description: This is an example schema
1227        "#;
1228        let doc = MarkedYaml::load_from_str(yaml).expect("Failed to load YAML");
1229        let marked_yaml = doc.first().unwrap();
1230        assert!(marked_yaml.data.is_mapping());
1231        let YamlData::Mapping(mapping) = &marked_yaml.data else {
1232            panic!("Expected a mapping");
1233        };
1234        let metadata_and_annotations = MetadataAndAnnotations::try_from(mapping).unwrap();
1235        assert_eq!(
1236            metadata_and_annotations.id,
1237            Some("http://example.com/schema".to_string())
1238        );
1239        assert_eq!(
1240            metadata_and_annotations.schema,
1241            Some("http://example.com/schema".to_string())
1242        );
1243        assert_eq!(
1244            metadata_and_annotations.title,
1245            Some("Example Schema".to_string())
1246        );
1247        assert_eq!(
1248            metadata_and_annotations.description,
1249            Some("This is an example schema".to_string())
1250        );
1251    }
1252
1253    #[test]
1254    fn test_yaml_schema_with_multiple_types() {
1255        let yaml = r#"
1256        type:
1257          - boolean
1258          - number
1259          - integer
1260          - string
1261        "#;
1262        let doc = MarkedYaml::load_from_str(yaml).expect("Failed to load YAML");
1263        let marked_yaml = doc.first().unwrap();
1264        let yaml_schema = YamlSchema::try_from(marked_yaml).unwrap();
1265        let YamlSchema::Subschema(subschema) = yaml_schema else {
1266            panic!("Expected a subschema");
1267        };
1268        assert!(!subschema.r#type.is_none());
1269        assert!(subschema.r#type.is_multiple());
1270        let SchemaType::Multiple(type_values) = subschema.r#type else {
1271            panic!("Expected a multiple type");
1272        };
1273        assert_eq!(type_values, vec!["boolean", "number", "integer", "string"]);
1274    }
1275
1276    #[test]
1277    fn test_multiple_types() {
1278        let schema = r#"
1279        type:
1280          - string
1281          - number
1282        "#;
1283        let schema = loader::load_from_str(schema).unwrap();
1284
1285        let s = "I'm a string";
1286        let docs = MarkedYaml::load_from_str(s).unwrap();
1287        let value = docs.first().unwrap();
1288        let context = Context::default();
1289        let result = schema.validate(&context, value);
1290        assert!(result.is_ok());
1291        assert!(!context.has_errors());
1292
1293        let s = "42";
1294        let docs = MarkedYaml::load_from_str(s).unwrap();
1295        let value = docs.first().unwrap();
1296        let context = Context::default();
1297        let result = schema.validate(&context, value);
1298        assert!(result.is_ok());
1299        assert!(!context.has_errors());
1300
1301        let s = "null";
1302        let docs = MarkedYaml::load_from_str(s).unwrap();
1303        let value = docs.first().unwrap();
1304        let context = Context::default();
1305        let result = schema.validate(&context, value);
1306        assert!(result.is_ok());
1307        assert!(context.has_errors());
1308        let errors = context.errors.borrow();
1309        assert_eq!(errors.len(), 1);
1310        assert_eq!(errors[0].error, "None of type: [string, number] matched");
1311    }
1312
1313    #[test]
1314    fn properties_without_type_infers_object_and_validates() {
1315        let yaml = r#"
1316        properties:
1317          foo:
1318            type: string
1319        required:
1320          - foo
1321        "#;
1322        let root = loader::load_from_str(yaml).unwrap();
1323        let YamlSchema::Subschema(sub) = &root.schema else {
1324            panic!("expected subschema");
1325        };
1326        assert!(sub.r#type.is_none(), "expected type: none");
1327        assert!(sub.object_schema.is_some());
1328
1329        let ok = engine::Engine::evaluate(&root, "foo: bar", false).unwrap();
1330        assert!(!ok.has_errors());
1331
1332        let bad = engine::Engine::evaluate(&root, "other: x", false).unwrap();
1333        assert!(bad.has_errors());
1334    }
1335
1336    #[test]
1337    fn test_object_schema_with_const_property() {
1338        let schema = r#"
1339        type: object
1340        properties:
1341          const:
1342            description: A scalar value that must match the value
1343            type:
1344              - string
1345              - integer
1346              - number
1347              - boolean
1348        "#;
1349        let schema = loader::load_from_str(schema).expect("Failed to load schema");
1350
1351        let docs = MarkedYaml::load_from_str(
1352            r#"
1353        const: "I'm a string"
1354        "#,
1355        )
1356        .unwrap();
1357        let value = docs.first().unwrap();
1358        let context = Context::default();
1359        let result = schema.validate(&context, value);
1360        assert!(result.is_ok());
1361        assert!(!context.has_errors());
1362    }
1363
1364    #[test]
1365    fn unevaluated_properties_all_of_extra_key_rejected() {
1366        let root = loader::load_from_str(
1367            r#"
1368            allOf:
1369              - properties:
1370                  a:
1371                    type: string
1372              - unevaluatedProperties: false
1373            "#,
1374        )
1375        .unwrap();
1376        let ok = engine::Engine::evaluate(&root, "a: ok", false).unwrap();
1377        assert!(!ok.has_errors());
1378        let bad = engine::Engine::evaluate(&root, "a: ok\nb: no", false).unwrap();
1379        assert!(bad.has_errors());
1380    }
1381}