use serde_json::{Map, 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 choices: Vec<Value>,
pub 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 = schema.get("minimum").and_then(Value::as_f64).map(f64::ceil);
let maximum = schema
.get("maximum")
.and_then(Value::as_f64)
.map(f64::floor);
let (floor, ceiling) = (minimum.and_then(as_bound), maximum.and_then(as_bound));
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,
choices: schema
.get("enum")
.and_then(Value::as_array)
.map(|values| values.to_vec())
.unwrap_or_default(),
unsatisfiable: minimum.is_some_and(|value| value > i64::MAX as f64)
|| maximum.is_some_and(|value| value < i64::MIN as f64)
|| matches!((floor, ceiling), (Some(low), Some(high)) if low > high),
items,
}
}
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_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"
);
}
}