jsonschema 0.46.3

JSON schema validaton library
Documentation
#![cfg(not(target_arch = "wasm32"))]

use ahash::HashMap;
use serde::Deserialize;
use serde_json::Value;
use std::{fs, path::Path};

#[derive(Debug, Deserialize)]
struct AnnotationTestFile {
    suite: Vec<SuiteCase>,
}

#[derive(Debug, Deserialize)]
struct SuiteCase {
    description: String,
    schema: Value,
    tests: Vec<TestCase>,
}

#[derive(Debug, Deserialize)]
struct TestCase {
    instance: Value,
    assertions: Vec<Assertion>,
}

#[derive(Debug, Deserialize)]
struct Assertion {
    location: String,
    keyword: String,
    expected: HashMap<String, Value>,
}

fn collect_annotations(
    evaluation: &jsonschema::Evaluation,
) -> HashMap<(String, String), Vec<Value>> {
    let mut result: HashMap<(String, String), Vec<Value>> = HashMap::default();

    for entry in evaluation.iter_annotations() {
        let instance_loc = entry.instance_location.as_str().to_string();
        let annotations = entry.annotations.value();
        let keyword = keyword_from_schema_location(entry.schema_location);

        if is_schema_valued_keyword(keyword) {
            push_annotation(&mut result, &instance_loc, keyword, annotations);
        } else if let Some(annotation_bundle) = annotations.as_object() {
            for (bundle_keyword, value) in annotation_bundle {
                push_annotation(&mut result, &instance_loc, bundle_keyword, value);
            }
        } else {
            push_annotation(&mut result, &instance_loc, keyword, annotations);
        }
    }

    result
}

fn push_annotation(
    result: &mut HashMap<(String, String), Vec<Value>>,
    instance_location: &str,
    keyword: &str,
    value: &Value,
) {
    let key = (instance_location.to_owned(), keyword.to_owned());
    result.entry(key).or_default().push(value.clone());
}

fn keyword_from_schema_location(schema_location: &str) -> &str {
    schema_location
        .rsplit('/')
        .next()
        .unwrap_or(schema_location)
}

fn is_schema_valued_keyword(keyword: &str) -> bool {
    keyword == "contentSchema"
}

#[test]
fn test_annotation_suite() {
    let suite_path = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("suite")
        .join("annotations")
        .join("tests");

    let mut failures: Vec<String> = Vec::new();

    let mut entries: Vec<_> = fs::read_dir(&suite_path)
        .expect("Failed to read annotation test directory")
        .filter_map(Result::ok)
        .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
        .collect();
    entries.sort_by_key(std::fs::DirEntry::path);

    for entry in entries {
        let filepath = entry.path();
        let filename = filepath.file_name().unwrap().to_str().unwrap();

        let content = fs::read_to_string(&filepath)
            .unwrap_or_else(|err| panic!("Failed to read {}: {err}", filepath.display()));

        let test_file: AnnotationTestFile = serde_json::from_str(&content)
            .unwrap_or_else(|err| panic!("Failed to parse {}: {err}", filepath.display()));

        for suite_case in &test_file.suite {
            let description = &suite_case.description;
            let schema = &suite_case.schema;

            let validator = match jsonschema::options().build(schema) {
                Ok(v) => v,
                Err(e) => {
                    let test_id = format!("{filename} / {description} / 0");
                    failures.push(format!(
                        "FAILED to build schema for {test_id}: {e}\nSchema: {}",
                        serde_json::to_string_pretty(schema).unwrap()
                    ));
                    continue;
                }
            };

            for (test_idx, test_case) in suite_case.tests.iter().enumerate() {
                let test_id = format!("{filename} / {description} / {test_idx}");

                let evaluation = validator.evaluate(&test_case.instance);
                let collected = collect_annotations(&evaluation);

                let mut test_failed = false;
                let mut test_errors: Vec<String> = Vec::new();

                for assertion in &test_case.assertions {
                    let location = &assertion.location;
                    let keyword = &assertion.keyword;
                    let expected = &assertion.expected;

                    let key = (location.clone(), keyword.clone());
                    let actual_values = collected.get(&key).cloned().unwrap_or_default();

                    if expected.is_empty() {
                        if !actual_values.is_empty() {
                            test_failed = true;
                            test_errors.push(format!(
                                "  Expected no annotation for keyword '{keyword}' at '{location}', but got: {actual_values:?}"
                            ));
                        }
                    } else {
                        for (schema_loc, expected_value) in expected {
                            if !actual_values.contains(expected_value) {
                                test_failed = true;
                                test_errors.push(format!(
                                    "  Keyword: {keyword:?}\n  Instance: {location:?}\n  Schema: {schema_loc:?}\n  Expected: {expected_value}\n  Got: {actual_values:?}"
                                ));
                            }
                        }
                    }
                }

                if test_failed {
                    failures.push(format!(
                        "FAILED: {test_id}\nInstance: {}\n{}",
                        serde_json::to_string(&test_case.instance).unwrap(),
                        test_errors.join("\n")
                    ));
                }
            }
        }
    }

    assert!(
        failures.is_empty(),
        "\n{} annotation test(s) failed:\n\n{}",
        failures.len(),
        failures.join("\n\n")
    );
}