use serde_json::{Map, Number, Value};
use super::schema::resolved;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Bounds {
pub pattern: Option<String>,
pub min_length: Option<usize>,
pub minimum: Option<i64>,
pub maximum: Option<i64>,
pub number_minimum: Option<Number>,
pub number_maximum: Option<Number>,
pub number_exclusive_minimum: Option<Number>,
pub number_exclusive_maximum: Option<Number>,
pub choices: Vec<Value>,
pub unsatisfiable: bool,
pub number_unsatisfiable: bool,
pub items: Option<Box<Bounds>>,
}
pub(super) fn bounds_of(schema: &Value, defs: &Map<String, Value>) -> Bounds {
bounds_opening(schema, defs, &mut Vec::new())
}
fn bounds_opening<'a>(
schema: &'a Value,
defs: &'a Map<String, Value>,
open: &mut Vec<&'a Value>,
) -> Bounds {
let depth = open.len();
let written = schema;
let schema = resolved(schema, defs);
open.push(written);
open.push(schema);
let items = schema
.get("items")
.filter(|items| items.is_object())
.map(|items| resolved(items, defs))
.filter(|item| !open.iter().any(|seen| core::ptr::eq(*seen, *item)))
.map(|item| Box::new(bounds_opening(item, defs, open)));
open.truncate(depth);
let minimum = integer_end(schema.get("minimum"), End::Floor);
let exclusive_minimum = integer_end(schema.get("exclusiveMinimum"), End::StrictFloor);
let maximum = integer_end(schema.get("maximum"), End::Ceiling);
let exclusive_maximum = integer_end(schema.get("exclusiveMaximum"), End::StrictCeiling);
let (floor, ceiling) = (
tighter_floor(minimum.bound, exclusive_minimum.bound),
tighter_ceiling(maximum.bound, exclusive_maximum.bound),
);
let number_bound = |keyword: &str| schema.get(keyword).and_then(Value::as_number).cloned();
let (number_minimum, number_maximum) = (number_bound("minimum"), number_bound("maximum"));
let (number_exclusive_minimum, number_exclusive_maximum) = (
number_bound("exclusiveMinimum"),
number_bound("exclusiveMaximum"),
);
let number_holds_nothing = number_range_is_empty(
number_minimum.as_ref(),
number_maximum.as_ref(),
number_exclusive_minimum.as_ref(),
number_exclusive_maximum.as_ref(),
);
Bounds {
pattern: schema
.get("pattern")
.and_then(Value::as_str)
.map(str::to_owned),
min_length: schema
.get("minLength")
.and_then(Value::as_u64)
.and_then(|count| usize::try_from(count).ok()),
minimum: floor,
maximum: ceiling,
number_minimum,
number_maximum,
number_exclusive_minimum,
number_exclusive_maximum,
choices: schema
.get("enum")
.and_then(Value::as_array)
.map(|values| values.to_vec())
.unwrap_or_default(),
unsatisfiable: minimum.empty
|| exclusive_minimum.empty
|| maximum.empty
|| exclusive_maximum.empty
|| matches!((floor, ceiling), (Some(low), Some(high)) if low > high),
number_unsatisfiable: number_holds_nothing,
items,
}
}
#[derive(Clone, Copy)]
enum End {
Floor,
StrictFloor,
Ceiling,
StrictCeiling,
}
struct IntegerEnd {
bound: Option<i64>,
empty: bool,
}
fn integer_end(keyword: Option<&Value>, end: End) -> IntegerEnd {
let Some(number) = keyword.and_then(Value::as_number) else {
return IntegerEnd {
bound: None,
empty: false,
};
};
if let Some(written) = number.as_i64() {
return exact(written, end);
}
let Some(value) = number.as_f64() else {
return IntegerEnd {
bound: None,
empty: false,
};
};
if value.fract() == 0.0 && value >= i64::MIN as f64 && value < i64::MAX as f64 {
#[expect(
clippy::cast_possible_truncation,
reason = "guarded: the value is integral and inside an i64's reach"
)]
return exact(value as i64, end);
}
let rounded = match end {
End::Floor => value.ceil(),
End::StrictFloor => value.floor() + 1.0,
End::Ceiling => value.floor(),
End::StrictCeiling => value.ceil() - 1.0,
};
IntegerEnd {
bound: as_bound(rounded),
empty: match end {
End::Floor | End::StrictFloor => value >= i64::MAX as f64,
End::Ceiling | End::StrictCeiling => value <= i64::MIN as f64,
},
}
}
fn exact(written: i64, end: End) -> IntegerEnd {
let bound = match end {
End::Floor | End::Ceiling => Some(written),
End::StrictFloor => written.checked_add(1),
End::StrictCeiling => written.checked_sub(1),
};
IntegerEnd {
bound,
empty: bound.is_none(),
}
}
fn tighter_floor(minimum: Option<i64>, exclusive_minimum: Option<i64>) -> Option<i64> {
match (minimum, exclusive_minimum) {
(Some(low), Some(strict)) => Some(low.max(strict)),
(Some(low), None) => Some(low),
(None, Some(strict)) => Some(strict),
(None, None) => None,
}
}
fn tighter_ceiling(maximum: Option<i64>, exclusive_maximum: Option<i64>) -> Option<i64> {
match (maximum, exclusive_maximum) {
(Some(high), Some(strict)) => Some(high.min(strict)),
(Some(high), None) => Some(high),
(None, Some(strict)) => Some(strict),
(None, None) => None,
}
}
fn number_range_is_empty(
minimum: Option<&Number>,
maximum: Option<&Number>,
exclusive_minimum: Option<&Number>,
exclusive_maximum: Option<&Number>,
) -> bool {
let low = minimum.and_then(Number::as_f64);
let high = maximum.and_then(Number::as_f64);
let above = exclusive_minimum.and_then(Number::as_f64);
let below = exclusive_maximum.and_then(Number::as_f64);
matches!((low, high), (Some(l), Some(h)) if l > h)
|| matches!((low, below), (Some(l), Some(h)) if l >= h)
|| matches!((above, high), (Some(l), Some(h)) if l >= h)
|| matches!((above, below), (Some(l), Some(h)) if l >= h)
}
fn as_bound(value: f64) -> Option<i64> {
#[expect(
clippy::cast_possible_truncation,
reason = "guarded by the range check, and a float-to-int cast saturates"
)]
(value >= i64::MIN as f64 && value <= i64::MAX as f64).then_some(value as i64)
}
#[cfg(test)]
mod tests {
use super::super::FieldSet;
use super::super::fixtures::{props, real_field_set};
use super::*;
use serde_json::json;
#[test]
fn a_pattern_is_read_through_every_hop_a_schema_can_put_it_behind() {
let defs = props(json!({
"UpDuration": { "type": "string", "pattern": r"^\d+(ms|h|m|s)?$" },
}));
let p = props(json!({
"inline": { "type": "string", "pattern": r"^\d+(ms|h|m|s)?$" },
"one_hop": { "$ref": "#/$defs/UpDuration" },
"two_hops": { "anyOf": [{ "$ref": "#/$defs/UpDuration" }, { "type": "null" }] },
"plain": { "type": "string" },
}));
let set = FieldSet::from_properties(&p, &defs, &[]);
for key in ["inline", "one_hop", "two_hops"] {
assert_eq!(
set.by_key(key).unwrap().bounds.pattern.as_deref(),
Some(r"^\d+(ms|h|m|s)?$"),
"{key}"
);
}
assert_eq!(set.by_key("plain").unwrap().bounds, Bounds::default());
}
#[test]
fn integer_bounds_are_read_and_a_fractional_one_is_normalised() {
let p = props(json!({
"keep": { "type": "integer", "minimum": 1 },
"ratio": { "type": "integer", "minimum": 0.5, "maximum": 2.5 },
"both": { "type": "integer", "minimum": -3, "maximum": 64 },
"plain": { "type": "integer" },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
assert_eq!(set.by_key("keep").unwrap().bounds.minimum, Some(1));
assert_eq!(set.by_key("both").unwrap().bounds.minimum, Some(-3));
assert_eq!(set.by_key("both").unwrap().bounds.maximum, Some(64));
let ratio = &set.by_key("ratio").unwrap().bounds;
assert_eq!(ratio.minimum, Some(1), "0.5 rounds up, so 0 is refused");
assert_eq!(ratio.maximum, Some(2), "2.5 rounds down, so 3 is refused");
assert!(!ratio.unsatisfiable);
assert_eq!(set.by_key("plain").unwrap().bounds, Bounds::default());
}
#[test]
fn a_number_fields_bounds_are_kept_as_the_schema_wrote_them() {
let p = props(json!({
"per_item_usd": { "type": "number", "format": "double", "exclusiveMinimum": 0 },
"ratio": { "type": "number", "minimum": 0.5, "maximum": 2.5 },
"below": { "type": "number", "exclusiveMaximum": 10 },
"plain": { "type": "number" },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
let bounds = |key: &str| set.by_key(key).expect(key).bounds.clone();
let bound = |value: Value| value.as_number().expect("a number").clone();
assert_eq!(
bounds("per_item_usd").number_exclusive_minimum,
Some(bound(json!(0)))
);
assert_eq!(
(
bounds("ratio").number_minimum,
bounds("ratio").number_maximum
),
(Some(bound(json!(0.5))), Some(bound(json!(2.5)))),
"a decimal bound is not rounded"
);
assert_eq!(
bounds("below").number_exclusive_maximum,
Some(bound(json!(10)))
);
assert_eq!(
bounds("ratio"),
Bounds {
number_minimum: Some(bound(json!(0.5))),
number_maximum: Some(bound(json!(2.5))),
minimum: Some(1),
maximum: Some(2),
..Default::default()
},
"both halves are read, and neither replaces the other"
);
assert_eq!(
bounds("plain"),
Bounds::default(),
"no bound, nothing to check"
);
}
#[test]
fn an_integers_exclusive_bounds_fold_into_the_pair_the_gate_reads() {
let p = props(json!({
"above_zero": { "type": "integer", "exclusiveMinimum": 0 },
"below_ten": { "type": "integer", "exclusiveMaximum": 10 },
"above_a_fraction": { "type": "integer", "exclusiveMinimum": 1.2 },
"below_a_fraction": { "type": "integer", "exclusiveMaximum": 9.5 },
"tighter_of_two": { "type": "integer", "minimum": 3, "exclusiveMinimum": 5 },
"looser_of_two": { "type": "integer", "minimum": 5, "exclusiveMinimum": 2 },
"plain": { "type": "integer" },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
let bounds = |key: &str| set.by_key(key).expect(key).bounds.clone();
assert_eq!(
(bounds("above_zero").minimum, bounds("above_zero").maximum),
(Some(1), None),
"the integers above 0 start at 1"
);
assert_eq!(
(bounds("below_ten").minimum, bounds("below_ten").maximum),
(None, Some(9)),
"the integers below 10 end at 9"
);
assert_eq!(
bounds("above_a_fraction").minimum,
Some(2),
"the integers above 1.2 start at 2"
);
assert_eq!(
bounds("below_a_fraction").maximum,
Some(9),
"the integers below 9.5 end at 9"
);
assert_eq!(
bounds("tighter_of_two").minimum,
Some(6),
"exclusiveMinimum: 5 binds past minimum: 3"
);
assert_eq!(
bounds("looser_of_two").minimum,
Some(5),
"and the tighter of the two is what the field is held to"
);
assert_eq!(
bounds("plain"),
Bounds::default(),
"no bound, nothing to fold"
);
}
#[test]
fn an_integers_strict_bound_is_folded_past_a_floats_exact_integers() {
let p = props(json!({
"past_2_53": { "type": "integer", "exclusiveMinimum": 9007199254740992i64 },
"past_2_53_as_a_float": {
"type": "integer", "exclusiveMinimum": 9007199254740992.0
},
"under_negative_2_53": { "type": "integer", "exclusiveMaximum": -9007199254740992i64 },
"strict_at_i64_max": { "type": "integer", "exclusiveMinimum": i64::MAX },
"strict_at_i64_min": { "type": "integer", "exclusiveMaximum": i64::MIN },
"inclusive_at_i64_max": { "type": "integer", "minimum": i64::MAX },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
let bounds = |key: &str| set.by_key(key).expect(key).bounds.clone();
assert_eq!(
bounds("past_2_53").minimum,
Some(9007199254740993),
"the next integer up, not the same float read back"
);
assert_eq!(
bounds("past_2_53_as_a_float").minimum,
Some(9007199254740993),
"the same integer spelled with a decimal point"
);
assert_eq!(
bounds("under_negative_2_53").maximum,
Some(-9007199254740993)
);
for key in ["past_2_53", "past_2_53_as_a_float", "under_negative_2_53"] {
assert!(!bounds(key).unsatisfiable, "{key} admits plenty");
}
let top = bounds("strict_at_i64_max");
assert_eq!(top.minimum, None, "no integer sits above i64::MAX");
assert!(
top.unsatisfiable,
"so the schema asks for a range with nothing in it"
);
let bottom = bounds("strict_at_i64_min");
assert_eq!(bottom.maximum, None, "no integer sits below i64::MIN");
assert!(bottom.unsatisfiable);
let inclusive = bounds("inclusive_at_i64_max");
assert_eq!(inclusive.minimum, Some(i64::MAX));
assert!(!inclusive.unsatisfiable);
}
#[test]
fn a_decimal_range_with_nothing_in_it_is_recorded_as_such() {
let p = props(json!({
"inverted": { "type": "number", "minimum": 2.5, "maximum": 1.5 },
"met_at_a_strict_ceiling": { "type": "number", "minimum": 5, "exclusiveMaximum": 5 },
"met_at_a_strict_floor": { "type": "number", "exclusiveMinimum": 5, "maximum": 5 },
"strict_and_inverted": { "type": "number", "exclusiveMinimum": 5, "exclusiveMaximum": 5 },
"meets_inclusively": { "type": "number", "minimum": 5, "maximum": 5 },
"fractional_but_full": { "type": "number", "minimum": 1.2, "maximum": 1.8 },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
for key in [
"inverted",
"met_at_a_strict_ceiling",
"met_at_a_strict_floor",
"strict_and_inverted",
] {
assert!(
set.by_key(key).expect(key).bounds.number_unsatisfiable,
"{key} admits nothing"
);
}
for key in ["meets_inclusively", "fractional_but_full"] {
assert!(
!set.by_key(key).expect(key).bounds.number_unsatisfiable,
"{key}"
);
}
assert!(
set.by_key("fractional_but_full")
.expect("fractional_but_full")
.bounds
.unsatisfiable,
"the integer pair beside it is empty, and that is not this field's problem"
);
}
#[test]
fn a_numeric_bound_that_is_not_a_number_is_read_as_no_bound() {
let p = props(json!({
"ratio": { "type": "number", "minimum": "0.5", "maximum": null },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
let bounds = &set.by_key("ratio").expect("ratio").bounds;
assert_eq!(bounds.number_minimum, None);
assert_eq!(bounds.number_maximum, None);
}
#[test]
fn a_range_with_nothing_in_it_is_recorded_as_such() {
let p = props(json!({
"inverted": { "type": "integer", "minimum": 3, "maximum": 1 },
"inverted_after_rounding": {
"type": "integer", "minimum": 1.2, "maximum": 1.8,
},
"past_i64": { "type": "integer", "minimum": 1e30 },
"under_i64": { "type": "integer", "maximum": -1e30 },
"wide_open": { "type": "integer", "minimum": -1e30, "maximum": 1e30 },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
for key in [
"inverted",
"inverted_after_rounding",
"past_i64",
"under_i64",
] {
assert!(set.by_key(key).unwrap().bounds.unsatisfiable, "{key}");
}
let open = &set.by_key("wide_open").unwrap().bounds;
assert!(!open.unsatisfiable);
assert_eq!((open.minimum, open.maximum), (None, None));
}
#[test]
fn an_arrays_item_bounds_are_read_inline_through_a_ref_and_through_an_anyof() {
let defs = props(json!({
"Slug": { "type": "string", "pattern": "^[a-z-]+$", "minLength": 3 },
"Count": { "type": "integer", "minimum": 1, "maximum": 64 },
}));
let p = props(json!({
"inline": { "type": "array", "items": { "type": "string", "minLength": 2 } },
"one_hop": { "type": "array", "items": { "$ref": "#/$defs/Slug" } },
"two_hops": {
"type": "array",
"items": { "anyOf": [{ "$ref": "#/$defs/Slug" }, { "type": "null" }] },
},
"numbers": { "type": "array", "items": { "$ref": "#/$defs/Count" } },
"plain": { "type": "array", "items": { "type": "string" } },
"scalar": { "type": "string", "minLength": 3 },
}));
let set = FieldSet::from_properties(&p, &defs, &[]);
let items = |key: &str| {
set.by_key(key)
.expect(key)
.bounds
.items
.as_deref()
.unwrap_or_else(|| panic!("{key} declares an item schema"))
.clone()
};
assert_eq!(items("inline").min_length, Some(2));
assert_eq!(items("one_hop").pattern.as_deref(), Some("^[a-z-]+$"));
assert_eq!(
items("two_hops").min_length,
Some(3),
"the anyOf arm must be resolved before its bounds are read"
);
assert_eq!(
(items("numbers").minimum, items("numbers").maximum),
(Some(1), Some(64))
);
assert_eq!(items("plain"), Bounds::default());
assert_eq!(set.by_key("scalar").unwrap().bounds.items, None);
}
#[test]
fn an_item_schema_that_names_its_own_array_ends_the_walk() {
let p = props(json!({
"tree": { "type": "array", "items": { "$ref": "#/$defs/Tree" } },
}));
let defs = props(json!({
"Tree": { "type": "array", "items": { "$ref": "#/$defs/Tree" }, "enum": ["a"] },
}));
let set = FieldSet::from_properties(&p, &defs, &[]);
let bounds = &set.by_key("tree").unwrap().bounds;
let item = bounds.items.as_deref().expect("the array declares items");
assert_eq!(
item.choices,
vec![json!("a")],
"the def's own bounds are read"
);
assert_eq!(
item.items.as_deref(),
None,
"the hop back to a schema already on the path says nothing"
);
}
#[test]
fn an_item_schema_that_is_only_an_alias_for_another_ends_the_walk() {
let p = props(json!({ "tree": { "$ref": "#/$defs/A" } }));
let defs = props(json!({
"A": { "type": "array", "items": { "$ref": "#/$defs/B" }, "minLength": 1 },
"B": { "$ref": "#/$defs/A" },
}));
let set = FieldSet::from_properties(&p, &defs, &[]);
let bounds = &set.by_key("tree").unwrap().bounds;
assert_eq!(bounds.min_length, Some(1), "the property's own def");
let item = bounds.items.as_deref().expect("A declares an item schema");
assert_eq!(
item.min_length,
Some(1),
"the alias resolves to A, whose bounds are read"
);
assert_eq!(
item.items.as_deref(),
None,
"the hop back to A says nothing, and the walk stops one level deeper"
);
}
#[test]
fn an_item_schema_behind_two_nullable_aliases_ends_the_walk() {
let p = props(json!({ "tree": { "$ref": "#/$defs/A" } }));
let defs = props(json!({
"A": { "type": "array", "items": { "$ref": "#/$defs/B" }, "minLength": 1 },
"B": { "anyOf": [{ "$ref": "#/$defs/C" }, { "type": "null" }] },
"C": { "anyOf": [{ "$ref": "#/$defs/A" }, { "type": "null" }] },
}));
let set = FieldSet::from_properties(&p, &defs, &[]);
let bounds = &set.by_key("tree").unwrap().bounds;
let item = bounds.items.as_deref().expect("A declares an item schema");
assert_eq!(item.min_length, Some(1));
assert_eq!(item.items.as_deref(), None, "the hop back to A ends it");
}
#[test]
fn an_items_enum_is_kept_as_the_values_the_schema_wrote() {
let p = props(json!({
"tags": { "type": "array", "items": { "enum": ["ask", "auto"] } },
"levels": { "type": "array", "items": { "enum": [1, 2, 3] } },
"open": { "type": "array", "items": { "type": "string" } },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
let items = |key: &str| {
set.by_key(key)
.unwrap()
.bounds
.items
.as_deref()
.expect("items")
.clone()
};
assert_eq!(items("tags").choices, vec![json!("ask"), json!("auto")]);
assert_eq!(items("levels").choices, vec![json!(1), json!(2), json!(3)]);
assert!(items("open").choices.is_empty(), "no enum, no choices");
}
#[test]
fn the_real_schema_publishes_the_bounds_the_gate_reads() {
let set = real_field_set();
assert_eq!(
set.by_key("max_memory").unwrap().bounds.pattern.as_deref(),
Some(r"^\d+(G|M|K)?$")
);
assert_eq!(
set.by_key("kill_timeout")
.unwrap()
.bounds
.pattern
.as_deref(),
Some(r"^\d+(ms|h|m|s)?$")
);
assert_eq!(set.by_key("max_restarts").unwrap().bounds.minimum, Some(0));
assert_eq!(
set.by_key("cwd").unwrap().bounds,
Bounds::default(),
"a plain path field is held to nothing"
);
}
}