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