use crate::{CompatibilityError, Role, SchemaDocument, check_compat};
use json_schema_ast::{NodeId, SchemaNode, SchemaNodeKind};
use json_schema_fuzz::{GenerationConfig, ValueGenerator};
use rand::{SeedableRng, rngs::StdRng};
use serde_json::{Value, json};
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum CompatibilityResult {
Compatible,
Incompatible {
direction: Role,
counterexample: Value,
},
Unknown {
reason: String,
},
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AnalysisOptions {
pub max_generated_candidates: usize,
pub max_depth: u8,
pub seed: u64,
}
impl Default for AnalysisOptions {
fn default() -> Self {
Self {
max_generated_candidates: 16,
max_depth: 8,
seed: 0x434f4d504154,
}
}
}
pub fn analyze_compat(
old: &SchemaDocument,
new: &SchemaDocument,
role: Role,
) -> Result<CompatibilityResult, CompatibilityError> {
analyze_compat_with_options(old, new, role, AnalysisOptions::default())
}
pub fn analyze_compat_with_options(
old: &SchemaDocument,
new: &SchemaDocument,
role: Role,
options: AnalysisOptions,
) -> Result<CompatibilityResult, CompatibilityError> {
if check_compat(old, new, role)? {
return Ok(CompatibilityResult::Compatible);
}
let mut rng = StdRng::seed_from_u64(options.seed);
for direction in [Role::Serializer, Role::Deserializer] {
if role != Role::Both && direction != role {
continue;
}
let (source, target) = if direction == Role::Serializer {
(new, old)
} else {
(old, new)
};
let mut candidates = vec![
json!(null),
json!(false),
json!(true),
json!(0),
json!(1),
json!(-1),
json!(0.5),
json!(""),
json!("a"),
json!([]),
json!([0, 0]),
json!({}),
];
literals(source.source_schema_json(), &mut candidates);
literals(target.source_schema_json(), &mut candidates);
let config = GenerationConfig::new(options.max_depth)
.with_max_generation_attempts(std::num::NonZeroUsize::new(8).unwrap())
.with_max_candidate_nodes(std::num::NonZeroUsize::new(4096).unwrap());
let generated = (0..options.max_generated_candidates)
.map_while(|_| ValueGenerator::generate(source, config, &mut rng).ok());
for value in candidates.into_iter().chain(generated) {
if source.is_valid(&value)?
&& !target.is_valid(&value)?
&& (direction == Role::Serializer
|| emitted(source.root()?, &value, &mut HashSet::new()))
{
return Ok(CompatibilityResult::Incompatible {
direction,
counterexample: value,
});
}
}
}
Ok(CompatibilityResult::Unknown {
reason: format!(
"{}; no validated counterexample was found (up to {} generated candidates per direction, depth {})",
crate::explain_compat_failure(old, new, role)?
.unwrap_or_else(|| "the structural prover could not establish inclusion".into()),
options.max_generated_candidates,
options.max_depth
),
})
}
fn literals(schema: &Value, candidates: &mut Vec<Value>) {
let Some(object) = schema.as_object() else {
return;
};
if let Some(value) = object.get("const") {
candidates.push(value.clone());
}
if let Some(values) = object.get("enum").and_then(Value::as_array) {
candidates.extend(values.iter().cloned());
}
for key in [
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
] {
if let Some(value) = object.get(key) {
candidates.push(value.clone());
}
}
for key in json_schema_ast::SINGLE_SCHEMA_CHILD_KEYWORDS {
if let Some(child) = object.get(key) {
literals(child, candidates);
}
}
for key in json_schema_ast::SCHEMA_MAP_CHILD_KEYWORDS {
if let Some(children) = object.get(key).and_then(Value::as_object) {
for child in children.values() {
literals(child, candidates);
}
}
}
for key in json_schema_ast::SCHEMA_ARRAY_CHILD_KEYWORDS {
if let Some(children) = object.get(key).and_then(Value::as_array) {
for child in children {
literals(child, candidates);
}
}
}
}
fn emitted(schema: &SchemaNode, value: &Value, active: &mut HashSet<(NodeId, usize)>) -> bool {
let key = (schema.id(), std::ptr::from_ref(value) as usize);
if !active.insert(key) {
return false;
}
let result = match (schema.kind(), value) {
(
SchemaNodeKind::Object {
properties,
required,
dependent_required,
..
},
Value::Object(values),
) => values.iter().all(|(name, value)| {
if let Some(child) = properties.get(name) {
emitted(child, value, active)
} else {
required.contains(name)
|| dependent_required.iter().any(|(trigger, names)| {
values.contains_key(trigger) && names.contains(name)
})
}
}),
(
SchemaNodeKind::Array {
prefix_items,
items,
contains: None,
..
},
Value::Array(values),
) => values
.iter()
.enumerate()
.all(|(index, value)| emitted(prefix_items.get(index).unwrap_or(items), value, active)),
(SchemaNodeKind::AnyOf(branches), _) => branches
.iter()
.any(|branch| branch.accepts_value(value) && emitted(branch, value, active)),
_ => true,
};
active.remove(&key);
result
}