Skip to main content

yaml_schema/
loader.rs

1//! The loader module loads the YAML schema from a file into the in-memory model
2
3use std::path::Path;
4use std::time::Duration;
5
6use reqwest::Url;
7use reqwest::blocking::Client;
8use saphyr::LoadableYamlNode;
9use saphyr::MarkedYaml;
10use saphyr::Scalar;
11use saphyr::YamlData;
12use url::Url as ParseUrl;
13
14use crate::Error;
15use crate::Number;
16use crate::Result;
17use crate::RootSchema;
18use crate::schemas::BooleanOrSchema;
19use crate::schemas::YamlSchema;
20use crate::utils::format_marker;
21use crate::utils::scalar_to_string;
22use crate::utils::try_unwrap_saphyr_scalar;
23
24/// Load a YAML schema from a file.
25/// Delegates to the `load_from_doc` function to load the schema from the first document.
26/// Sets `base_uri` to the canonical file URL for resolving relative `$ref` values.
27pub fn load_file<S: AsRef<str>>(path: S) -> Result<RootSchema> {
28    let fs_metadata = std::fs::metadata(path.as_ref())?;
29    if !fs_metadata.is_file() {
30        return Err(Error::FileNotFound(path.as_ref().to_string()));
31    }
32    let s = std::fs::read_to_string(path.as_ref())?;
33    let mut root = load_from_str(&s)?;
34    let canonical = Path::new(path.as_ref()).canonicalize()?;
35    root.base_uri = Some(
36        ParseUrl::from_file_path(canonical)
37            .map_err(|_| Error::GenericError("Failed to convert file path to URL".to_string()))?,
38    );
39    Ok(root)
40}
41
42/// Load a YAML schema from a &str.
43pub fn load_from_str(s: &str) -> Result<RootSchema> {
44    let docs = MarkedYaml::load_from_str(s).map_err(Error::YamlParsingError)?;
45    load_from_docs(docs)
46}
47
48/// Load a RootSchema from Vec of docs.
49pub fn load_from_docs<'f>(docs: Vec<MarkedYaml<'f>>) -> Result<RootSchema> {
50    let Some(first_doc) = docs.first() else {
51        return Ok(RootSchema::empty());
52    };
53    load_from_doc(first_doc)
54}
55
56/// Load a YAML schema from a document. Basically just a wrapper around the TryFrom<&MarkedYaml<'_>> for RootSchema.
57pub fn load_from_doc<'f>(doc: &MarkedYaml<'f>) -> Result<RootSchema> {
58    RootSchema::try_from(doc)
59}
60
61/// Error type for URL loading operations
62#[derive(thiserror::Error, Debug)]
63pub enum UrlLoadError {
64    #[error("Failed to download from URL: {0}")]
65    DownloadError(#[from] reqwest::Error),
66
67    #[error("Failed to parse URL: {0}")]
68    ParseUrlError(#[from] url::ParseError),
69
70    #[error("Failed to parse YAML: {0}")]
71    ParseError(#[from] saphyr::ScanError),
72
73    #[error("No YAML documents found in the downloaded content")]
74    NoDocuments,
75}
76
77impl From<reqwest::Error> for crate::Error {
78    fn from(value: reqwest::Error) -> Self {
79        crate::Error::UrlLoadError(UrlLoadError::DownloadError(value))
80    }
81}
82
83/// Load a schema from string content with an optional base URI for resolving relative $ref values.
84pub fn load_from_content(content: &str, base_uri: Option<ParseUrl>) -> Result<RootSchema> {
85    let docs = MarkedYaml::load_from_str(content).map_err(Error::YamlParsingError)?;
86    let doc = docs
87        .first()
88        .ok_or_else(|| crate::generic_error!("No YAML documents in content"))?;
89    let mut root = load_from_doc(doc)?;
90    root.base_uri = base_uri;
91    Ok(root)
92}
93
94/// Load a schema from a URL (file:// or http(s)://). Used for external $ref resolution.
95pub fn load_external_schema(doc_url: &str) -> Result<RootSchema> {
96    let parsed = ParseUrl::parse(doc_url).map_err(|e| Error::UrlLoadError(e.into()))?;
97    match parsed.scheme() {
98        "file" => {
99            let path = parsed
100                .to_file_path()
101                .map_err(|_| Error::GenericError("Invalid file URL".to_string()))?;
102            let path_str = path
103                .to_str()
104                .ok_or_else(|| Error::GenericError("Non-UTF-8 file path".to_string()))?;
105            load_file(path_str)
106        }
107        "http" | "https" => {
108            let (content, url) = fetch_url(doc_url, None)?;
109            load_from_content(&content, Some(url))
110        }
111        _ => Err(Error::GenericError(format!(
112            "Unsupported URL scheme for $ref: {}",
113            parsed.scheme()
114        ))),
115    }
116}
117
118/// Reads the first YAML document and returns the string value of a top-level `$schema` key, if present.
119///
120/// Returns `Ok(None)` when there is no document, the root is not a mapping, or `$schema` is absent.
121/// Returns an error if `$schema` is present but not a string.
122pub fn extract_dollar_schema_from_yaml(contents: &str) -> Result<Option<String>> {
123    let docs = MarkedYaml::load_from_str(contents).map_err(Error::YamlParsingError)?;
124    let Some(first) = docs.first() else {
125        return Ok(None);
126    };
127    match &first.data {
128        YamlData::Mapping(mapping) => {
129            let key = MarkedYaml::value_from_str("$schema");
130            match mapping.get(&key) {
131                Some(v) => Ok(Some(marked_yaml_to_string(v, "$schema must be a string")?)),
132                None => Ok(None),
133            }
134        }
135        _ => Ok(None),
136    }
137}
138
139/// Loads a root schema from a `$schema` reference: `http`/`https`/`file` URLs via [`load_external_schema`],
140/// otherwise as a filesystem path (relative paths are resolved against `instance_parent`).
141///
142/// Returns the loaded schema and a URI string suitable for [`RootSchema::cache_key`] fallback / preloaded map keys
143/// (matches `base_uri` after load).
144pub fn load_root_schema_from_ref(
145    schema_ref: &str,
146    instance_parent: &Path,
147) -> Result<(RootSchema, String)> {
148    let trimmed = schema_ref.trim();
149    if trimmed.is_empty() {
150        return Err(crate::generic_error!("$schema value is empty"));
151    }
152
153    let root = match ParseUrl::parse(trimmed) {
154        Ok(parsed) if matches!(parsed.scheme(), "http" | "https" | "file") => {
155            load_external_schema(trimmed)?
156        }
157        Ok(parsed) => {
158            return Err(crate::generic_error!(
159                "Unsupported URL scheme in $schema: {}",
160                parsed.scheme()
161            ));
162        }
163        Err(_) => {
164            let path = Path::new(trimmed);
165            let resolved = if path.is_absolute() {
166                path.to_path_buf()
167            } else {
168                instance_parent.join(path)
169            };
170            let path_str = resolved
171                .to_str()
172                .ok_or_else(|| Error::GenericError("Non-UTF-8 schema path".to_string()))?;
173            load_file(path_str)?
174        }
175    };
176
177    let fallback = root
178        .base_uri
179        .as_ref()
180        .map(|u| u.to_string())
181        .ok_or_else(|| {
182            Error::GenericError("Internal error: loaded schema missing base URI".to_string())
183        })?;
184
185    Ok((root, fallback))
186}
187
188/// Fetches content from a URL. Returns the response body as a String and the request URL.
189///
190/// The HTTP call runs on a dedicated OS thread so that `reqwest::blocking`
191/// does not conflict with an already-running async (tokio) runtime.
192pub fn fetch_url(url_string: &str, timeout_seconds: Option<u64>) -> Result<(String, Url)> {
193    let url_owned = url_string.to_string();
194    let timeout = Duration::from_secs(timeout_seconds.unwrap_or(30));
195
196    std::thread::spawn(move || {
197        // rustls-tls avoids the openssl-sys build dependency that broke pre-commit.ci: https://github.com/yaml-schema/yaml-schema/pull/72
198        let client = Client::builder()
199            .timeout(timeout)
200            .use_rustls_tls()
201            .build()?;
202
203        let url = Url::parse(&url_owned).map_err(|e| Error::UrlLoadError(e.into()))?;
204
205        let response = client.get(url.clone()).send()?;
206        if !response.status().is_success() {
207            match response.error_for_status() {
208                Ok(_) => unreachable!(),
209                Err(e) => return Err(e.into()),
210            }
211        }
212
213        let content = response.text()?;
214        Ok((content, url))
215    })
216    .join()
217    .unwrap_or_else(|_| {
218        Err(Error::GenericError(
219            "HTTP fetch thread panicked".to_string(),
220        ))
221    })
222}
223
224/// Downloads a YAML schema from a URL and parses it into a YamlSchema
225///
226/// # Arguments
227/// * `url` - The URL to download the YAML schema from
228/// * `timeout_seconds` - Optional timeout in seconds for the HTTP request (default: 30 seconds)
229///
230/// # Returns
231/// A `Result` containing the parsed `YamlSchema` if successful, or an error if the download or parsing fails.
232///
233/// # Example
234/// ```no_run
235/// use yaml_schema::loader::download_from_url;
236///
237/// let schema = download_from_url("https://example.com/schema.yaml", None).unwrap();
238/// ```
239pub fn download_from_url(url_string: &str, timeout_seconds: Option<u64>) -> Result<RootSchema> {
240    let (yaml_content, url) = fetch_url(url_string, timeout_seconds)?;
241
242    // Parse the YAML content
243    let docs = MarkedYaml::load_from_str(&yaml_content).map_err(UrlLoadError::ParseError)?;
244
245    match docs.first() {
246        Some(doc) => {
247            let mut root = load_from_doc(doc)?;
248            root.base_uri = Some(url);
249            Ok(root)
250        }
251        None => Err(UrlLoadError::NoDocuments.into()),
252    }
253}
254
255pub fn marked_yaml_to_string<S: Into<String> + Copy>(yaml: &MarkedYaml, msg: S) -> Result<String> {
256    if let YamlData::Value(Scalar::String(s)) = &yaml.data {
257        Ok(s.to_string())
258    } else {
259        Err(Error::ExpectedScalar(msg.into()))
260    }
261}
262
263/// Property name / mapping key as a string, matching instance validation (`scalar_to_string`).
264///
265/// YAML may parse unquoted keys as integers or floats; quote the key in YAML if you need a specific
266/// string label (e.g. `"1"` vs `1`).
267pub fn marked_yaml_mapping_key_to_string(yaml: &MarkedYaml) -> Result<String> {
268    if let YamlData::Value(scalar) = &yaml.data {
269        Ok(scalar_to_string(scalar))
270    } else {
271        Err(expected_scalar!(
272            "[{}] Expected a scalar mapping key, got: {:?}",
273            format_marker(&yaml.span.start),
274            yaml
275        ))
276    }
277}
278
279pub fn load_array_of_schemas_marked<'f>(value: &MarkedYaml<'f>) -> Result<Vec<YamlSchema>> {
280    if let YamlData::Sequence(values) = &value.data {
281        values
282            .iter()
283            .map(|v| {
284                if v.is_mapping() {
285                    v.try_into()
286                } else {
287                    Err(generic_error!("Expected a mapping, but got: {:?}", v))
288                }
289            })
290            .collect::<Result<Vec<YamlSchema>>>()
291    } else {
292        Err(generic_error!(
293            "{} Expected a sequence, but got: {:?}",
294            format_marker(&value.span.start),
295            value
296        ))
297    }
298}
299
300pub fn load_integer(value: &saphyr::Yaml) -> Result<i64> {
301    let scalar = try_unwrap_saphyr_scalar(value)?;
302    match scalar {
303        saphyr::Scalar::Integer(i) => Ok(*i),
304        _ => Err(unsupported_type!(
305            "Expected type: integer, but got: {:?}",
306            value
307        )),
308    }
309}
310
311pub fn load_integer_marked(value: &MarkedYaml) -> Result<i64> {
312    if let YamlData::Value(Scalar::Integer(i)) = &value.data {
313        Ok(*i)
314    } else {
315        Err(generic_error!(
316            "{} Expected integer value, got: {:?}",
317            format_marker(&value.span.start),
318            value
319        ))
320    }
321}
322
323pub fn load_number(value: &saphyr::Yaml) -> Result<Number> {
324    let scalar = try_unwrap_saphyr_scalar(value)?;
325    match scalar {
326        Scalar::Integer(i) => Ok(Number::integer(*i)),
327        Scalar::FloatingPoint(o) => Ok(Number::float(o.into_inner())),
328        _ => Err(unsupported_type!(
329            "Expected type: integer or float, but got: {:?}",
330            value
331        )),
332    }
333}
334
335pub fn load_array_items_marked<'input>(value: &MarkedYaml<'input>) -> Result<BooleanOrSchema> {
336    match &value.data {
337        YamlData::Value(scalar) => {
338            if let Scalar::Boolean(b) = scalar {
339                Ok(BooleanOrSchema::Boolean(*b))
340            } else {
341                Err(generic_error!(
342                    "array: boolean or mapping with type or $ref, but got: {:?}",
343                    value
344                ))
345            }
346        }
347        YamlData::Mapping(_mapping) => {
348            let schema: YamlSchema = value.try_into()?;
349            Ok(BooleanOrSchema::schema(schema))
350        }
351        _ => Err(generic_error!(
352            "array: boolean or mapping with type or $ref, but got: {:?}",
353            value
354        )),
355    }
356}
357
358/// Load a boolean or schema mapping (e.g. `additionalProperties`, `unevaluatedProperties`, `unevaluatedItems`).
359pub fn load_boolean_or_schema_marked(value: &MarkedYaml<'_>) -> Result<BooleanOrSchema> {
360    match &value.data {
361        YamlData::Value(scalar) => match scalar {
362            Scalar::Boolean(b) => Ok(BooleanOrSchema::Boolean(*b)),
363            _ => Err(generic_error!(
364                "{} Expected a boolean scalar, but got: {:?}",
365                format_marker(&value.span.start),
366                scalar
367            )),
368        },
369        YamlData::Mapping(_) => {
370            let schema: YamlSchema = value.try_into()?;
371            Ok(BooleanOrSchema::schema(schema))
372        }
373        _ => Err(unsupported_type!(
374            "Expected boolean or mapping, but got: {:?}",
375            value
376        )),
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use regex::Regex;
383    use saphyr::LoadableYamlNode;
384    use saphyr::MarkedYaml;
385
386    use crate::ConstValue;
387    use crate::Engine;
388    use crate::Result;
389    use crate::Validator as _;
390    use crate::loader;
391    use crate::schemas::EnumSchema;
392    use crate::schemas::IntegerSchema;
393    use crate::schemas::SchemaType;
394    use crate::schemas::StringSchema;
395
396    use super::*;
397
398    #[test]
399    fn test_boolean_literal_true() {
400        let root_schema = load_from_doc(&MarkedYaml::value_from_str("true")).unwrap();
401        assert_eq!(root_schema.schema, YamlSchema::BooleanLiteral(true));
402    }
403
404    #[test]
405    fn test_boolean_literal_false() {
406        let root_schema = load_from_doc(&MarkedYaml::value_from_str("false")).unwrap();
407        assert_eq!(root_schema.schema, YamlSchema::BooleanLiteral(false));
408    }
409
410    #[test]
411    fn test_const_string() {
412        let docs = MarkedYaml::load_from_str("const: string value").unwrap();
413        let root_schema = load_from_doc(docs.first().unwrap()).unwrap();
414        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
415            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
416        };
417        assert_eq!(subschema.r#const, Some(ConstValue::string("string value")));
418    }
419
420    #[test]
421    fn test_const_integer() {
422        let docs = MarkedYaml::load_from_str("const: 42").unwrap();
423        let root_schema = load_from_doc(docs.first().unwrap()).unwrap();
424        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
425            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
426        };
427        assert_eq!(subschema.r#const, Some(ConstValue::integer(42)));
428    }
429
430    #[test]
431    fn test_const_array() {
432        let docs = MarkedYaml::load_from_str("const: [1, 2]").unwrap();
433        let root_schema = load_from_doc(docs.first().unwrap()).unwrap();
434        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
435            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
436        };
437        let expected = ConstValue::Array(vec![ConstValue::integer(1), ConstValue::integer(2)]);
438        assert_eq!(subschema.r#const, Some(expected));
439    }
440
441    #[test]
442    fn test_const_object() {
443        let docs = MarkedYaml::load_from_str("const:\n  a: 1").unwrap();
444        let root_schema = load_from_doc(docs.first().unwrap()).unwrap();
445        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
446            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
447        };
448        let mut expected_obj = hashlink::LinkedHashMap::new();
449        expected_obj.insert("a".into(), ConstValue::integer(1));
450        assert_eq!(subschema.r#const, Some(ConstValue::Object(expected_obj)));
451    }
452
453    #[test]
454    fn test_type_foo_should_error() {
455        let docs = MarkedYaml::load_from_str("type: foo").unwrap();
456        let root_schema = load_from_doc(docs.first().unwrap());
457        assert!(root_schema.is_err());
458        assert_eq!(
459            root_schema.unwrap_err().to_string(),
460            "Unsupported type: Expected type: string, number, integer, object, array, boolean, or null, but got: foo"
461        );
462    }
463
464    #[test]
465    fn test_type_string() {
466        let docs = MarkedYaml::load_from_str("type: string").unwrap();
467        let root_schema = load_from_doc(docs.first().unwrap()).unwrap();
468        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
469            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
470        };
471        assert_eq!(subschema.r#type, SchemaType::new("string"));
472    }
473
474    #[test]
475    fn test_type_object_with_string_with_description() {
476        let root_schema = loader::load_from_str(
477            r#"
478            type: object
479            properties:
480                name:
481                    type: string
482                    description: This is a description
483        "#,
484        )
485        .expect("Failed to load schema");
486        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
487            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
488        };
489        let Some(object_schema) = &subschema.object_schema else {
490            panic!(
491                "Expected ObjectSchema, but got: {:?}",
492                &subschema.object_schema
493            );
494        };
495        let name_property = object_schema
496            .properties
497            .as_ref()
498            .expect("Expected properties")
499            .get("name")
500            .expect("Expected `name` property");
501
502        let YamlSchema::Subschema(name_property_schema) = &name_property else {
503            panic!(
504                "Expected Subschema for `name` property, but got: {:?}",
505                &name_property
506            );
507        };
508        assert_eq!(name_property_schema.r#type, SchemaType::new("string"));
509        assert_eq!(
510            name_property_schema.string_schema,
511            Some(StringSchema::default())
512        );
513        assert_eq!(
514            name_property_schema.metadata_and_annotations.description,
515            Some("This is a description".to_string())
516        );
517    }
518
519    #[test]
520    fn test_type_string_with_pattern() {
521        let root_schema = loader::load_from_str(
522            r#"
523        type: string
524        pattern: "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$"
525        "#,
526        )
527        .unwrap();
528        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
529            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
530        };
531        assert_eq!(subschema.r#type, SchemaType::new("string"));
532        let expected = StringSchema {
533            pattern: Some(Regex::new("^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$").unwrap()),
534            ..Default::default()
535        };
536
537        assert_eq!(subschema.string_schema, Some(expected));
538    }
539
540    #[test]
541    fn test_integer_schema() {
542        let root_schema = loader::load_from_str("type: integer").unwrap();
543        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
544            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
545        };
546        let integer_schema = IntegerSchema::default();
547        assert_eq!(subschema.integer_schema, Some(integer_schema));
548    }
549
550    #[test]
551    fn test_enum() {
552        let root_schema = loader::load_from_str(
553            r#"
554        enum:
555          - foo
556          - bar
557          - baz
558        "#,
559        )
560        .unwrap();
561        let enum_values = ["foo", "bar", "baz"]
562            .iter()
563            .map(|s| ConstValue::string(s.to_string()))
564            .collect::<Vec<ConstValue>>();
565        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
566            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
567        };
568        assert_eq!(
569            subschema.r#enum,
570            Some(EnumSchema {
571                r#enum: enum_values
572            })
573        );
574    }
575
576    #[test]
577    fn test_enum_without_type() {
578        let root_schema = loader::load_from_str(
579            r#"
580            enum:
581              - red
582              - amber
583              - green
584              - null
585              - 42
586            "#,
587        )
588        .unwrap();
589        let enum_values = vec![
590            ConstValue::string("red".to_string()),
591            ConstValue::string("amber".to_string()),
592            ConstValue::string("green".to_string()),
593            ConstValue::null(),
594            ConstValue::integer(42),
595        ];
596        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
597            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
598        };
599        assert_eq!(
600            subschema.r#enum,
601            Some(EnumSchema {
602                r#enum: enum_values
603            })
604        );
605    }
606
607    #[test]
608    fn test_defs() {
609        let root_schema = loader::load_from_str(
610            r##"
611            $defs:
612              foo:
613                type: boolean
614            "##,
615        )
616        .unwrap();
617        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
618            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
619        };
620        assert!(subschema.defs.is_some());
621        let Some(defs) = &subschema.defs else {
622            panic!("Expected defs, but got: {:?}", &subschema.defs);
623        };
624        assert_eq!(defs.len(), 1);
625        assert_eq!(defs.get("foo"), Some(&YamlSchema::typed_boolean()));
626    }
627
628    #[test]
629    fn test_one_of_with_ref() {
630        let root_schema = loader::load_from_str(
631            r##"
632            $defs:
633              foo:
634                type: boolean
635            oneOf:
636              - type: string
637              - $ref: "#/$defs/foo"
638            "##,
639        )
640        .unwrap();
641        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
642            panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
643        };
644        assert!(subschema.one_of.is_some());
645        let Some(one_of) = &subschema.one_of else {
646            panic!("Expected oneOf, but got: {:?}", &subschema.one_of);
647        };
648        assert_eq!(one_of.one_of.len(), 2);
649        assert_eq!(
650            one_of.one_of[0],
651            YamlSchema::typed_string(StringSchema::default()),
652            "one_of[0] should be a string schema"
653        );
654        assert_eq!(
655            one_of.one_of[1],
656            YamlSchema::ref_str("#/$defs/foo"),
657            "one_of[1] should be a reference to '#/$defs/foo'"
658        );
659
660        let s = r#"
661        false
662        "#;
663        let docs = MarkedYaml::load_from_str(s).unwrap();
664        let value = docs.first().unwrap();
665        let context = crate::Context::with_root_schema(&root_schema, true);
666        let result = root_schema.validate(&context, value);
667        assert!(result.is_ok());
668        assert!(!context.has_errors());
669    }
670
671    #[test]
672    fn extract_dollar_schema_from_mapping() {
673        let yaml = "$schema: ./x.yaml\nfoo: 1\n";
674        assert_eq!(
675            extract_dollar_schema_from_yaml(yaml).unwrap(),
676            Some("./x.yaml".to_string())
677        );
678    }
679
680    #[test]
681    fn extract_dollar_schema_missing() {
682        assert_eq!(extract_dollar_schema_from_yaml("foo: 1\n").unwrap(), None);
683    }
684
685    #[test]
686    fn extract_dollar_schema_non_mapping_root() {
687        assert_eq!(extract_dollar_schema_from_yaml("- a\n").unwrap(), None);
688    }
689
690    #[test]
691    fn extract_dollar_schema_not_string_errors() {
692        let result = extract_dollar_schema_from_yaml("$schema: 42\n");
693        assert!(result.is_err());
694    }
695
696    #[test]
697    fn load_root_schema_from_ref_relative_path() {
698        let dir = std::env::temp_dir().join(format!("yaml_schema_ref_test_{}", std::process::id()));
699        std::fs::create_dir_all(&dir).expect("create temp dir");
700        let schema_path = dir.join("sch.yaml");
701        std::fs::write(
702            &schema_path,
703            "type: object\nproperties:\n  a:\n    type: string\n",
704        )
705        .expect("write schema");
706        let (root, uri) = load_root_schema_from_ref("sch.yaml", &dir).expect("load");
707        assert!(uri.starts_with("file://"));
708        let YamlSchema::Subschema(sub) = &root.schema else {
709            panic!("expected Subschema");
710        };
711        assert_eq!(sub.r#type, SchemaType::new("object"));
712        std::fs::remove_dir_all(&dir).ok();
713    }
714
715    #[test]
716    fn test_self_validate() -> Result<()> {
717        let schema_filename = "yaml-schema.yaml";
718        let root_schema = match loader::load_file(schema_filename) {
719            Ok(schema) => schema,
720            Err(e) => {
721                eprintln!("Failed to read YAML schema file: {schema_filename}");
722                log::error!("{e}");
723                return Err(e);
724            }
725        };
726
727        let yaml_contents = std::fs::read_to_string(schema_filename)?;
728
729        let context = Engine::evaluate(&root_schema, &yaml_contents, false)?;
730        if context.has_errors() {
731            for error in context.errors.borrow().iter() {
732                eprintln!("{error}");
733            }
734        }
735        assert!(!context.has_errors());
736
737        Ok(())
738    }
739
740    // Regression test for https://github.com/yaml-schema/yaml-schema/issues/67:
741    // `required` must be an allowed keyword in the bundled meta-schema.
742    #[test]
743    fn test_meta_schema_accepts_required() -> Result<()> {
744        let root_schema = loader::load_file("yaml-schema.yaml")?;
745        let instance = r#"
746type: object
747properties:
748  any_property:
749    type: string
750required:
751  - any_property
752"#;
753        let context = Engine::evaluate(&root_schema, instance, false)?;
754        if context.has_errors() {
755            for error in context.errors.borrow().iter() {
756                eprintln!("{error}");
757            }
758        }
759        assert!(!context.has_errors());
760
761        Ok(())
762    }
763
764    #[test]
765    fn test_download_from_url() {
766        // This is an integration test that requires internet access
767        if std::env::var("CI").is_ok() {
768            // Skip in CI environments if needed
769            return;
770        }
771
772        let result = std::panic::catch_unwind(|| {
773            let url = "https://yaml-schema.net/yaml-schema.yaml";
774            let result = download_from_url(url, Some(10));
775
776            // Verify the download and parse was successful
777            let root_schema = result.expect("Failed to download and parse YAML schema from URL");
778
779            // Verify we got a valid schema with expected properties
780            let YamlSchema::Subschema(subschema) = &root_schema.schema else {
781                panic!("Expected Subschema, but got: {:?}", &root_schema.schema);
782            };
783            assert_eq!(subschema.r#type, SchemaType::new("object"));
784            assert!(subschema.object_schema.is_some());
785
786            // Verify the local schema is valid against the downloaded schema
787            if let Ok(local_schema) = std::fs::read_to_string("yaml-schema.yaml") {
788                let context = Engine::evaluate(&root_schema, &local_schema, false);
789                if let Ok(ctx) = context {
790                    if ctx.has_errors() {
791                        for error in ctx.errors.borrow().iter() {
792                            eprintln!("Validation error: {}", error);
793                        }
794                        panic!("Downloaded schema failed validation against local schema");
795                    }
796                } else if let Err(e) = context {
797                    panic!("Failed to validate downloaded schema: {}", e);
798                }
799            }
800        });
801
802        if let Err(e) = result {
803            // If the test fails due to network issues, mark it as passed with a warning
804            if let Some(s) = e.downcast_ref::<String>()
805                && (s.contains("Network is unreachable")
806                    || s.contains("failed to lookup address information"))
807            {
808                eprintln!("Warning: Network unreachable, skipping download test");
809                return;
810            }
811
812            // Re-panic if the failure wasn't network-related
813            std::panic::resume_unwind(e);
814        }
815    }
816}