Skip to main content

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}