jsoncompat 0.3.1

JSON Schema Compatibility Checker
Documentation
use json_schema_ast::SchemaDocument;
use json_schema_fuzz::{GenerationConfig, ValueGenerator};
use jsoncompat::{Role, check_compat};
use rand::{SeedableRng, rngs::StdRng};
use serde::Deserialize;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};

// datatest‑stable macro generates one test per fixture directory.
datatest_stable::harness! {
    { test = fixture, root = "tests/fixtures/backcompat", pattern = r".*[/\\]expect\.json$" },
}

#[derive(Deserialize)]
struct Expectation {
    serializer: bool,
    deserializer: bool,
    #[serde(default)]
    allowed_failure: bool,
}

#[derive(Deserialize, Default)]
struct SampleSets {
    #[serde(default)]
    old_only: Vec<Value>,
    #[serde(default)]
    new_only: Vec<Value>,
    #[serde(default)]
    both: Vec<Value>,
}

fn fixture(expect_file: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let dir: PathBuf = expect_file.parent().unwrap().into();
    let old_raw: Value = serde_json::from_slice(&fs::read(dir.join("old.json"))?)?;
    let new_raw: Value = serde_json::from_slice(&fs::read(dir.join("new.json"))?)?;
    let expect: Expectation = serde_json::from_slice(&fs::read(expect_file)?)?;

    // Build ASTs
    let old_schema = SchemaDocument::from_json(&old_raw)?;
    let new_schema = SchemaDocument::from_json(&new_raw)?;

    // Core compat result
    let ser = check_compat(&old_schema, &new_schema, Role::Serializer)?;
    let de = check_compat(&old_schema, &new_schema, Role::Deserializer)?;

    if expect.allowed_failure {
        if ser == expect.serializer && de == expect.deserializer {
            panic!("Previously-failing fixture now passes; remove allowed_failure from {dir:?}");
        }
    } else {
        assert_eq!(ser, expect.serializer, "serializer mismatch in {dir:?}");
        assert_eq!(de, expect.deserializer, "deserializer mismatch in {dir:?}");
    }

    // Load examples if present
    let ex_path = dir.join("examples.json");
    if ex_path.exists() {
        let samples: SampleSets = serde_json::from_slice(&fs::read(&ex_path)?)?;
        for v in &samples.old_only {
            assert!(
                old_schema.is_valid(v)?,
                "old_only invalid in {dir:?}: {v:?}"
            );
            assert!(
                !new_schema.is_valid(v)?,
                "old_only accepted by NEW in {dir:?}: {v:?}"
            );
        }
        for v in &samples.new_only {
            assert!(
                new_schema.is_valid(v)?,
                "new_only invalid in {dir:?}: {v:?}"
            );
            assert!(
                !old_schema.is_valid(v)?,
                "new_only accepted by OLD in {dir:?}: {v:?}"
            );
        }
        for v in &samples.both {
            assert!(
                old_schema.is_valid(v)? && new_schema.is_valid(v)?,
                "both sample invalid in {dir:?}: {v:?}"
            );
        }
    }

    // Quick fuzz confirmation (10 samples each direction)
    let mut rng = StdRng::seed_from_u64(0xDEADBEEF + dir.to_string_lossy().len() as u64);
    let config = GenerationConfig::new(4);

    for _ in 0..100 {
        let v_new = ValueGenerator::generate(&new_schema, config, &mut rng)?;
        if expect.serializer {
            assert!(
                old_schema.is_valid(&v_new)?,
                "serializer=true but OLD rejects generated value {v_new:?} in {dir:?}"
            );
        }

        let v_old = ValueGenerator::generate(&old_schema, config, &mut rng)?;
        if expect.deserializer {
            assert!(
                new_schema.is_valid(&v_old)?,
                "deserializer=true but NEW rejects generated value {v_old:?} in {dir:?}"
            );
        }
    }

    Ok(())
}