use serde_json::Value;
use std::collections::{HashMap, HashSet};
const MAX_RECURSION_DEPTH: usize = 64;
pub(crate) struct EffectiveSchema {
pub properties: HashMap<String, Value>,
pub required: HashSet<String>,
pub additional_properties: Option<Value>,
}
pub(crate) fn merge_additional_properties_constraint(
current: &mut Option<Value>,
candidate: &Value,
) {
match (current.as_ref(), candidate) {
(Some(Value::Bool(false)), _) => {}
(_, Value::Bool(false)) => *current = Some(Value::Bool(false)),
(None | Some(Value::Bool(true)), _) => *current = Some(candidate.clone()),
(Some(_), Value::Bool(true)) => {}
(Some(_), _) => *current = Some(candidate.clone()),
}
}
pub(crate) fn extract_effective_schema(schema: &Value) -> EffectiveSchema {
let mut eff = EffectiveSchema {
properties: HashMap::new(),
required: HashSet::new(),
additional_properties: None,
};
if let Value::Object(map) = schema {
if let Some(Value::Object(props)) = map.get("properties") {
for (k, v) in props {
eff.properties.insert(k.clone(), v.clone());
}
}
if let Some(Value::Array(req)) = map.get("required") {
for v in req {
if let Value::String(s) = v {
eff.required.insert(s.clone());
}
}
}
if let Some(ap) = map.get("additionalProperties") {
merge_additional_properties_constraint(&mut eff.additional_properties, ap);
}
if let Some(Value::Array(all_of)) = map.get("allOf") {
for item in all_of {
let item_eff = extract_effective_schema(item);
eff.properties.extend(item_eff.properties);
eff.required.extend(item_eff.required);
if let Some(ap) = item_eff.additional_properties {
merge_additional_properties_constraint(&mut eff.additional_properties, &ap);
}
}
}
}
eff
}
pub(crate) fn validate_schema_compatibility(
base_schema: &Value,
derived_schema: &Value,
base_id: &str,
derived_id: &str,
) -> Vec<String> {
let base = extract_effective_schema(base_schema);
let derived = extract_effective_schema(derived_schema);
let mut errors = validate_effective_schema_compatibility(&base, &derived, base_id, derived_id);
errors.extend(validate_closed_descendant_branches(
base_schema,
derived_schema,
base_id,
derived_id,
));
errors
}
pub(crate) fn validate_effective_schema_compatibility(
base: &EffectiveSchema,
derived: &EffectiveSchema,
base_id: &str,
derived_id: &str,
) -> Vec<String> {
let mut errors = Vec::new();
let base_disallows_additional = matches!(base.additional_properties, Some(Value::Bool(false)));
for (prop_name, derived_prop) in &derived.properties {
if let Some(base_prop) = base.properties.get(prop_name) {
if *derived_prop == Value::Bool(false) {
errors.push(format!(
"property '{prop_name}': derived schema '{derived_id}' disables property defined in base '{base_id}'"
));
continue;
}
compare_property_constraints(base_prop, derived_prop, prop_name, &mut errors);
}
else if base_disallows_additional {
errors.push(format!(
"property '{prop_name}': derived schema '{derived_id}' adds new property but base '{base_id}' has additionalProperties: false"
));
}
}
if base_disallows_additional {
let derived_explicitly_allows = match &derived.additional_properties {
Some(Value::Bool(false)) | None => false,
Some(_) => true,
};
if derived_explicitly_allows {
errors.push(format!(
"derived schema '{derived_id}' loosens additionalProperties from false in base '{base_id}'"
));
}
}
check_required_removal(base, derived, base_id, derived_id, &mut errors);
errors
}
pub(crate) fn validate_closed_descendant_branches(
ancestor_schema: &Value,
descendant_schema: &Value,
ancestor_label: &str,
descendant_label: &str,
) -> Vec<String> {
let mut errors = Vec::new();
collect_closed_descendant_branch_errors(
ancestor_schema,
descendant_schema,
"",
0,
ancestor_label,
descendant_label,
&mut errors,
);
errors
}
fn collect_closed_descendant_branch_errors(
ancestor_schema: &Value,
descendant_schema: &Value,
path: &str,
depth: usize,
ancestor_label: &str,
descendant_label: &str,
errors: &mut Vec<String>,
) {
if depth >= MAX_RECURSION_DEPTH {
errors.push(format!(
"schema compatibility check exceeded maximum nesting depth of \
{MAX_RECURSION_DEPTH} at '{path}' between ancestor '{ancestor_label}' \
and descendant '{descendant_label}'"
));
return;
}
let ancestor = extract_effective_schema(ancestor_schema);
let Some(descendant_obj) = descendant_schema.as_object() else {
return;
};
let descendant_props = descendant_obj.get("properties").and_then(Value::as_object);
if descendant_obj.get("additionalProperties") == Some(&Value::Bool(false)) {
let mut orphaned: Vec<&str> = ancestor
.properties
.keys()
.filter(|name| !descendant_props.is_some_and(|props| props.contains_key(name.as_str())))
.map(String::as_str)
.collect();
orphaned.sort_unstable();
for name in orphaned {
let property_path = join_schema_path(path, name);
errors.push(format!(
"property '{property_path}': descendant schema '{descendant_label}' sets \
additionalProperties: false but does not restate property defined in \
ancestor '{ancestor_label}', making it unusable under allOf composition"
));
}
}
if let Some(props) = descendant_props {
let mut common: Vec<&str> = props
.keys()
.filter(|name| ancestor.properties.contains_key(name.as_str()))
.map(String::as_str)
.collect();
common.sort_unstable();
for name in common {
let Some(ancestor_prop) = ancestor.properties.get(name) else {
continue;
};
let Some(descendant_prop) = props.get(name) else {
continue;
};
let next_path = join_schema_path(path, name);
collect_closed_descendant_branch_errors(
ancestor_prop,
descendant_prop,
&next_path,
depth + 1,
ancestor_label,
descendant_label,
errors,
);
}
}
if let Some(Value::Array(all_of)) = descendant_obj.get("allOf") {
for item in all_of {
collect_closed_descendant_branch_errors(
ancestor_schema,
item,
path,
depth + 1,
ancestor_label,
descendant_label,
errors,
);
}
}
}
fn join_schema_path(prefix: &str, name: &str) -> String {
if prefix.is_empty() {
name.to_owned()
} else {
format!("{prefix}.{name}")
}
}
fn compare_property_constraints(
base_prop: &Value,
derived_prop: &Value,
prop_name: &str,
errors: &mut Vec<String>,
) {
let Some(base_map) = base_prop.as_object() else {
return;
};
let Some(derived_map) = derived_prop.as_object() else {
errors.push(format!(
"property '{prop_name}': derived replaces schema object with a non-object value, \
loosening base constraints"
));
return;
};
check_type_compatibility(base_map, derived_map, prop_name, errors);
let derived_values = collect_derived_enumerated_values(derived_map);
let derived_enumerates_values = derived_values.is_some();
check_const_compatibility(base_map, derived_map, prop_name, errors);
if derived_enumerates_values {
check_enumerated_values_against_base(
base_map,
derived_values.as_deref().unwrap_or(&[]),
prop_name,
errors,
);
} else {
check_pattern_compatibility(base_map, derived_map, prop_name, errors);
check_upper_bound(base_map, derived_map, "maxLength", prop_name, errors);
check_upper_bound(base_map, derived_map, "maximum", prop_name, errors);
check_upper_bound(base_map, derived_map, "maxItems", prop_name, errors);
check_lower_bound(base_map, derived_map, "minLength", prop_name, errors);
check_lower_bound(base_map, derived_map, "minimum", prop_name, errors);
check_lower_bound(base_map, derived_map, "minItems", prop_name, errors);
}
check_enum_compatibility(base_map, derived_map, prop_name, errors);
check_items_compatibility(base_map, derived_map, prop_name, errors);
if base_map.get("type") == Some(&Value::String("object".to_owned()))
&& derived_map.get("type") == Some(&Value::String("object".to_owned()))
&& base_map.contains_key("properties")
{
let base_nested = extract_effective_schema(base_prop);
let derived_nested = extract_effective_schema(derived_prop);
let nested_errors = validate_effective_schema_compatibility(
&base_nested,
&derived_nested,
"base",
"derived",
);
for err in nested_errors {
errors.push(format!("in nested object '{prop_name}': {err}"));
}
}
}
fn check_type_compatibility(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let (Some(base_type), Some(derived_type)) = (base_map.get("type"), derived_map.get("type"))
&& base_type != derived_type
{
errors.push(format!(
"property '{prop_name}': derived changes type from {base_type} to {derived_type}"
));
}
}
fn check_const_compatibility(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let Some(base_const) = base_map.get("const") {
match derived_map.get("const") {
Some(derived_const) if base_const != derived_const => {
errors.push(format!(
"property '{prop_name}': derived redefines const from {base_const} to {derived_const}"
));
}
None => {
errors.push(format!(
"property '{prop_name}': derived omits const constraint ({base_const}) defined in base"
));
}
_ => {} }
}
}
fn check_pattern_compatibility(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let Some(base_pat) = base_map.get("pattern") {
match derived_map.get("pattern") {
Some(derived_pat) if base_pat != derived_pat => {
errors.push(format!(
"property '{prop_name}': derived changes pattern from {base_pat} to {derived_pat}"
));
}
None => {
errors.push(format!(
"property '{prop_name}': derived omits pattern constraint ({base_pat}) defined in base"
));
}
_ => {} }
}
}
fn check_enum_compatibility(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let Some(Value::Array(base_enum)) = base_map.get("enum") {
if let Some(Value::Array(derived_enum)) = derived_map.get("enum") {
for val in derived_enum {
if !base_enum.contains(val) {
errors.push(format!(
"property '{prop_name}': derived enum contains value {val} not in base enum"
));
}
}
return;
}
if let Some(derived_const) = derived_map.get("const") {
if !base_enum.contains(derived_const) {
errors.push(format!(
"property '{prop_name}': derived const {derived_const} is not in base enum"
));
}
return;
}
errors.push(format!(
"property '{prop_name}': derived omits enum constraint defined in base"
));
}
}
fn check_items_compatibility(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let Some(base_items) = base_map.get("items") {
match derived_map.get("items") {
Some(derived_items) => {
let items_name = format!("{prop_name}.items");
compare_property_constraints(base_items, derived_items, &items_name, errors);
}
None => {
errors.push(format!(
"property '{prop_name}': derived omits items constraint defined in base"
));
}
}
}
}
fn check_required_removal(
base: &EffectiveSchema,
derived: &EffectiveSchema,
base_id: &str,
derived_id: &str,
errors: &mut Vec<String>,
) {
if derived.required.is_empty() {
return;
}
for base_req in &base.required {
if !derived.required.contains(base_req) {
errors.push(format!(
"derived schema '{derived_id}' removes required field '{base_req}' defined in base '{base_id}'"
));
}
}
}
fn check_upper_bound(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
keyword: &str,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let Some(base_val) = base_map.get(keyword) {
match derived_map.get(keyword) {
Some(derived_val) => {
if let (Some(b), Some(d)) = (base_val.as_f64(), derived_val.as_f64())
&& d > b
{
errors.push(format!(
"property '{prop_name}': derived {keyword} ({d}) exceeds base {keyword} ({b})"
));
}
}
None => {
errors.push(format!(
"property '{prop_name}': derived omits {keyword} constraint ({base_val}) defined in base"
));
}
}
}
}
fn check_lower_bound(
base_map: &serde_json::Map<String, Value>,
derived_map: &serde_json::Map<String, Value>,
keyword: &str,
prop_name: &str,
errors: &mut Vec<String>,
) {
if let Some(base_val) = base_map.get(keyword) {
match derived_map.get(keyword) {
Some(derived_val) => {
if let (Some(b), Some(d)) = (base_val.as_f64(), derived_val.as_f64())
&& d < b
{
errors.push(format!(
"property '{prop_name}': derived {keyword} ({d}) is less than base {keyword} ({b})"
));
}
}
None => {
errors.push(format!(
"property '{prop_name}': derived omits {keyword} constraint ({base_val}) defined in base"
));
}
}
}
}
fn collect_derived_enumerated_values(
derived_map: &serde_json::Map<String, Value>,
) -> Option<Vec<Value>> {
if let Some(c) = derived_map.get("const") {
return Some(vec![c.clone()]);
}
if let Some(Value::Array(arr)) = derived_map.get("enum") {
return Some(arr.clone());
}
None
}
fn check_enumerated_values_against_base(
base_map: &serde_json::Map<String, Value>,
values: &[Value],
prop_name: &str,
errors: &mut Vec<String>,
) {
for keyword in &["minimum", "minLength", "minItems"] {
if let Some(base_val) = base_map.get(*keyword).and_then(Value::as_f64) {
for val in values {
let numeric: Option<f64> = match *keyword {
"minLength" => val
.as_str()
.and_then(|s| u32::try_from(s.len()).ok())
.map(f64::from),
"minItems" => val
.as_array()
.and_then(|a| u32::try_from(a.len()).ok())
.map(f64::from),
_ => val.as_f64(),
};
if let Some(n) = numeric
&& n < base_val
{
errors.push(format!(
"property '{prop_name}': derived const/enum value {val} violates \
base {keyword} ({base_val})"
));
}
}
}
}
for keyword in &["maximum", "maxLength", "maxItems"] {
if let Some(base_val) = base_map.get(*keyword).and_then(Value::as_f64) {
for val in values {
let numeric: Option<f64> = match *keyword {
"maxLength" => val
.as_str()
.and_then(|s| u32::try_from(s.len()).ok())
.map(f64::from),
"maxItems" => val
.as_array()
.and_then(|a| u32::try_from(a.len()).ok())
.map(f64::from),
_ => val.as_f64(),
};
if let Some(n) = numeric
&& n > base_val
{
errors.push(format!(
"property '{prop_name}': derived const/enum value {val} violates \
base {keyword} ({base_val})"
));
}
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_extract_simple_schema() {
let schema = json!({
"type": "object",
"required": ["a"],
"properties": {
"a": {"type": "string"},
"b": {"type": "integer"}
},
"additionalProperties": false
});
let eff = extract_effective_schema(&schema);
assert_eq!(eff.properties.len(), 2);
assert!(eff.required.contains("a"));
assert_eq!(eff.additional_properties, Some(Value::Bool(false)));
}
#[test]
fn test_extract_with_allof() {
let schema = json!({
"type": "object",
"allOf": [
{
"type": "object",
"required": ["x"],
"properties": {"x": {"type": "string"}}
},
{
"type": "object",
"required": ["y"],
"properties": {"y": {"type": "number"}}
}
]
});
let eff = extract_effective_schema(&schema);
assert_eq!(eff.properties.len(), 2);
assert!(eff.required.contains("x"));
assert!(eff.required.contains("y"));
}
#[test]
fn test_extract_allof_additional_properties_false_wins_over_true() {
let schema = json!({
"type": "object",
"additionalProperties": true,
"allOf": [
{
"type": "object",
"properties": {"a": {"type": "string"}},
"additionalProperties": false
},
{
"type": "object",
"properties": {"a": {"type": "string"}},
"additionalProperties": true
}
]
});
let eff = extract_effective_schema(&schema);
assert_eq!(eff.additional_properties, Some(Value::Bool(false)));
}
#[test]
fn test_compatible_tightening() {
let base = json!({
"type": "object",
"properties": {
"v": {"type": "string", "maxLength": 100}
}
});
let derived = json!({
"type": "object",
"properties": {
"v": {"type": "string", "maxLength": 50}
}
});
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(errs.is_empty(), "tightening should be ok: {errs:?}");
}
#[test]
fn test_incompatible_loosening_max_length() {
let base = json!({
"type": "object",
"properties": {
"v": {"type": "string", "maxLength": 100}
}
});
let derived = json!({
"type": "object",
"properties": {
"v": {"type": "string", "maxLength": 200}
}
});
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(!errs.is_empty());
}
#[test]
fn test_incompatible_loosening_maximum() {
let base = json!({
"type": "object",
"properties": {
"n": {"type": "integer", "maximum": 100}
}
});
let derived = json!({
"type": "object",
"properties": {
"n": {"type": "integer", "maximum": 200}
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(!errs.is_empty());
}
#[test]
fn test_incompatible_loosening_minimum() {
let base = json!({
"type": "object",
"properties": {
"n": {"type": "integer", "minimum": 10}
}
});
let derived = json!({
"type": "object",
"properties": {
"n": {"type": "integer", "minimum": 5}
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(!errs.is_empty());
}
#[test]
fn test_enum_expansion_fails() {
let base = json!({
"type": "object",
"properties": {
"s": {"type": "string", "enum": ["a", "b"]}
}
});
let derived = json!({
"type": "object",
"properties": {
"s": {"type": "string", "enum": ["a", "b", "c"]}
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(!errs.is_empty());
}
#[test]
fn test_enum_subset_ok() {
let base = json!({
"type": "object",
"properties": {
"s": {"type": "string", "enum": ["a", "b", "c"]}
}
});
let derived = json!({
"type": "object",
"properties": {
"s": {"type": "string", "enum": ["a"]}
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(errs.is_empty(), "{errs:?}");
}
#[test]
fn test_additional_properties_false_blocks_new_prop() {
let base = json!({
"type": "object",
"properties": {"a": {"type": "string"}},
"additionalProperties": false
});
let derived = json!({
"type": "object",
"properties": {
"a": {"type": "string"},
"b": {"type": "string"}
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(!errs.is_empty());
}
#[test]
fn test_value_compatibility_catches_closed_descendant_branch_orphan() {
let base = json!({
"type": "object",
"properties": {
"routing": {
"type": "object",
"properties": {
"source": {"type": "string"}
}
}
}
});
let derived = json!({
"type": "object",
"allOf": [
base,
{
"type": "object",
"properties": {
"routing": {
"type": "object",
"additionalProperties": false,
"properties": {
"target": {"type": "string"}
}
}
}
}
]
});
let errs = validate_schema_compatibility(&base, &derived, "base", "derived");
assert!(
errs.iter()
.any(|e| e.contains("routing.source") && e.contains("additionalProperties")),
"closed descendant branch should not orphan an ancestor property: {errs:?}"
);
}
#[test]
fn test_closed_descendant_branch_fails_when_depth_guard_is_hit() {
fn nested_object(depth: usize) -> Value {
let mut schema = json!({"type": "object", "properties": {}});
for _ in 0..depth {
schema = json!({
"type": "object",
"properties": {
"child": schema
}
});
}
schema
}
let base = nested_object(MAX_RECURSION_DEPTH);
let derived = nested_object(MAX_RECURSION_DEPTH);
let errs = validate_closed_descendant_branches(&base, &derived, "base", "derived");
assert!(
errs.iter()
.any(|err| err.contains("exceeded maximum nesting depth")),
"depth guard should fail closed instead of silently accepting: {errs:?}"
);
}
#[test]
fn test_additional_properties_inherited_via_allof_not_loosening() {
let base = json!({
"type": "object",
"properties": {"a": {"type": "string"}},
"additionalProperties": false
});
let derived = json!({
"type": "object",
"properties": {"a": {"type": "string"}}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
errs.is_empty(),
"Derived inheriting closedness via $ref should not be flagged: {errs:?}"
);
}
#[test]
fn test_additional_properties_explicit_true_still_loosens() {
let base = json!({
"type": "object",
"properties": {"a": {"type": "string"}},
"additionalProperties": false
});
let derived = json!({
"type": "object",
"properties": {"a": {"type": "string"}},
"additionalProperties": true
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
errs.iter()
.any(|e| e.contains("loosens additionalProperties")),
"Explicit additionalProperties: true must still flag as loosening: {errs:?}"
);
}
#[test]
fn test_open_base_allows_new_prop() {
let base = json!({
"type": "object",
"properties": {"a": {"type": "string"}}
});
let derived = json!({
"type": "object",
"properties": {
"a": {"type": "string"},
"b": {"type": "string"}
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(errs.is_empty(), "{errs:?}");
}
#[test]
fn test_property_disabled_fails() {
let base = json!({
"type": "object",
"required": ["x"],
"properties": {"x": {"type": "string"}}
});
let derived = json!({
"type": "object",
"properties": {"x": false}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(!errs.is_empty());
}
#[test]
fn test_nested_object_loosening_caught() {
let base = json!({
"type": "object",
"properties": {
"inner": {
"type": "object",
"properties": {
"v": {"type": "integer", "maximum": 10}
}
}
}
});
let derived = json!({
"type": "object",
"properties": {
"inner": {
"type": "object",
"properties": {
"v": {"type": "integer", "maximum": 20}
}
}
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(!errs.is_empty());
}
#[test]
fn test_boolean_true_schema_loosens_constrained_property() {
let base = json!({
"type": "object",
"properties": {
"age": {"type": "integer", "maximum": 120}
}
});
let derived = json!({
"type": "object",
"properties": {
"age": true
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
!errs.is_empty(),
"Boolean true schema should be flagged as loosening: {errs:?}"
);
}
#[test]
fn test_boolean_true_schema_loosens_typed_property() {
let base = json!({
"type": "object",
"properties": {
"name": {"type": "string"}
}
});
let derived = json!({
"type": "object",
"properties": {
"name": true
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
!errs.is_empty(),
"Boolean true schema replaces typed property - should flag"
);
}
#[test]
fn test_enum_tightening_allows_omitting_bounds() {
let base = json!({
"type": "object",
"properties": {
"tier": {"type": "string", "maxLength": 100}
}
});
let derived = json!({
"type": "object",
"properties": {
"tier": {"type": "string", "enum": ["gold", "platinum"]}
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
errs.is_empty(),
"enum tightening should allow omitting maxLength: {errs:?}"
);
}
#[test]
fn test_const_tightening_allows_omitting_bounds_and_pattern() {
let base = json!({
"type": "object",
"properties": {
"v": {"type": "string", "maxLength": 100, "pattern": "^[a-z]+$"}
}
});
let derived = json!({
"type": "object",
"properties": {
"v": {"type": "string", "const": "hello"}
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
errs.is_empty(),
"const tightening should allow omitting maxLength and pattern: {errs:?}"
);
}
#[test]
fn test_enum_tightening_allows_omitting_numeric_bounds() {
let base = json!({
"type": "object",
"properties": {
"priority": {"type": "integer", "minimum": 0, "maximum": 100}
}
});
let derived = json!({
"type": "object",
"properties": {
"priority": {"type": "integer", "enum": [1, 5, 10]}
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
errs.is_empty(),
"enum tightening should allow omitting min/max: {errs:?}"
);
}
#[test]
fn test_omitting_bounds_without_enum_or_const_still_fails() {
let base = json!({
"type": "object",
"properties": {
"code": {"type": "string", "maxLength": 100}
}
});
let derived = json!({
"type": "object",
"properties": {
"code": {"type": "string"}
}
});
let errs = validate_schema_compatibility(&base, &derived, "b", "d");
assert!(
!errs.is_empty(),
"Omitting maxLength without enum/const should still fail"
);
}
#[test]
fn test_derived_const_must_be_in_base_enum() {
let base = json!({
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["active", "inactive"]}
}
});
let derived_ok = json!({
"type": "object",
"properties": {
"status": {"type": "string", "const": "active"}
}
});
let errs = validate_schema_compatibility(&base, &derived_ok, "b", "d");
assert!(errs.is_empty(), "const in base enum should be ok: {errs:?}");
let derived_bad = json!({
"type": "object",
"properties": {
"status": {"type": "string", "const": "deleted"}
}
});
let errs = validate_schema_compatibility(&base, &derived_bad, "b", "d");
assert!(!errs.is_empty(), "const NOT in base enum should fail");
}
#[test]
fn test_const_violates_minimum() {
let base = json!({
"type": "object",
"properties": {
"score": {"type": "integer", "minimum": 42}
}
});
let derived = json!({
"type": "object",
"properties": {
"score": {"type": "integer", "const": 32}
}
});
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(
!errs.is_empty(),
"const 32 < minimum 42 should fail: {errs:?}"
);
assert!(
errs.iter()
.any(|e| e.contains("violates") && e.contains("minimum")),
"error should mention minimum violation: {errs:?}"
);
}
#[test]
fn test_const_satisfies_minimum() {
let base = json!({
"type": "object",
"properties": {
"score": {"type": "integer", "minimum": 42}
}
});
let derived = json!({
"type": "object",
"properties": {
"score": {"type": "integer", "const": 50}
}
});
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(
errs.is_empty(),
"const 50 >= minimum 42 should pass: {errs:?}"
);
}
#[test]
fn test_enum_value_violates_maximum() {
let base = json!({
"type": "object",
"properties": {
"score": {"type": "integer", "maximum": 100}
}
});
let derived = json!({
"type": "object",
"properties": {
"score": {"type": "integer", "enum": [10, 50, 200]}
}
});
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(
!errs.is_empty(),
"enum value 200 > maximum 100 should fail: {errs:?}"
);
}
#[test]
fn test_enum_values_within_bounds() {
let base = json!({
"type": "object",
"properties": {
"score": {"type": "integer", "minimum": 10, "maximum": 100}
}
});
let derived = json!({
"type": "object",
"properties": {
"score": {"type": "integer", "enum": [10, 50, 100]}
}
});
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(
errs.is_empty(),
"all enum values in range should pass: {errs:?}"
);
}
#[test]
fn test_const_string_violates_max_length() {
let base = json!({
"type": "object",
"properties": {
"code": {"type": "string", "maxLength": 5}
}
});
let derived = json!({
"type": "object",
"properties": {
"code": {"type": "string", "const": "toolong"}
}
});
let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~");
assert!(
!errs.is_empty(),
"const 'toolong' exceeds maxLength 5: {errs:?}"
);
}
}