kernel/records/param_form.rs
1//! Parameter normalization: coercing a wire value to a [`ParamSpec`]'s type and
2//! clamping numbers to its declared range. `merged` runs saved parameter values
3//! through [`ParamSpec::normalized`] so an out-of-range or wrong-typed value can
4//! never reach a runtime.
5
6use crate::records::{JsonValue, ParamSpec, ParamType};
7
8impl ParamSpec {
9 /// The integer `(min, max)` range, if the spec declares a two-element numeric
10 /// range with `min <= max`.
11 pub fn int_range(&self) -> Option<(i64, i64)> {
12 let [lower, upper] = self.range.as_deref()? else {
13 return None;
14 };
15 let lower = int_scalar(lower)?;
16 let upper = int_scalar(upper)?;
17 (lower <= upper).then_some((lower, upper))
18 }
19
20 /// The floating-point `(min, max)` range, if the spec declares a two-element
21 /// numeric range with `min <= max`.
22 pub fn double_range(&self) -> Option<(f64, f64)> {
23 let [lower, upper] = self.range.as_deref()? else {
24 return None;
25 };
26 let lower = double_scalar(lower)?;
27 let upper = double_scalar(upper)?;
28 (lower <= upper).then_some((lower, upper))
29 }
30
31 /// Coerce and clamp `value` to this spec, or `None` if it cannot be a value of
32 /// this type. Ints and floats are clamped to the range when one is declared
33 /// (a float coerces from an int and vice versa); an enum must be a listed
34 /// value; a bool or string must already match the type.
35 pub fn normalized(&self, value: &JsonValue) -> Option<JsonValue> {
36 match self.param_type {
37 ParamType::Int => {
38 let raw = int_scalar(value)?;
39 let clamped = match self.int_range() {
40 Some((lower, upper)) => raw.clamp(lower, upper),
41 None => raw,
42 };
43 Some(JsonValue::Int(clamped))
44 }
45 ParamType::Float => {
46 let raw = double_scalar(value)?;
47 // `max().min()` rather than `clamp()` so a stray non-finite value
48 // can't panic; finite values clamp identically.
49 let clamped = match self.double_range() {
50 Some((lower, upper)) => raw.max(lower).min(upper),
51 None => raw,
52 };
53 Some(JsonValue::Double(clamped))
54 }
55 ParamType::Enum => match value {
56 JsonValue::String(raw)
57 if self
58 .values
59 .as_ref()
60 .is_some_and(|allowed| allowed.contains(raw)) =>
61 {
62 Some(JsonValue::String(raw.clone()))
63 }
64 _ => None,
65 },
66 ParamType::Bool => match value {
67 JsonValue::Bool(raw) => Some(JsonValue::Bool(*raw)),
68 _ => None,
69 },
70 ParamType::String => match value {
71 JsonValue::String(raw) => Some(JsonValue::String(raw.clone())),
72 _ => None,
73 },
74 }
75 }
76}
77
78/// A value read as an integer: an int as-is, a float rounded to nearest.
79fn int_scalar(value: &JsonValue) -> Option<i64> {
80 match value {
81 JsonValue::Int(raw) => Some(*raw),
82 // `round()` is half-away-from-zero. A non-finite or out-of-range double
83 // saturates here rather than trapping.
84 JsonValue::Double(raw) => Some(raw.round() as i64),
85 _ => None,
86 }
87}
88
89/// A value read as a double: a double as-is, an int widened.
90fn double_scalar(value: &JsonValue) -> Option<f64> {
91 match value {
92 JsonValue::Int(raw) => Some(*raw as f64),
93 JsonValue::Double(raw) => Some(*raw),
94 _ => None,
95 }
96}