Skip to main content

siderite_validation/types/
float.rs

1//! Bounded floating-point newtype. Bounds are a type, not const generics,
2//! because `f64` cannot be a const generic parameter on stable Rust.
3
4use std::marker::PhantomData;
5
6use serde::de::Error as _;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8use serde_json::{Number, Value};
9
10use crate::context::ValidationContext;
11use crate::dump::Dump;
12use crate::error::FieldError;
13use crate::rules;
14use crate::schema::{Schema, SchemaObject, SchemaRegistry};
15use crate::validate::Validate;
16
17/// Inclusive/exclusive bounds for [`BoundedFloat`].
18pub trait FloatBounds {
19    /// Lower bound; `None` means unbounded below.
20    const MIN: Option<f64>;
21    /// Upper bound; `None` means unbounded above.
22    const MAX: Option<f64>;
23    /// When `true`, values equal to [`MIN`](Self::MIN) are rejected.
24    const EXCLUSIVE_MIN: bool = false;
25    /// When `true`, values equal to [`MAX`](Self::MAX) are rejected.
26    const EXCLUSIVE_MAX: bool = false;
27}
28
29/// Closed unit interval `0.0..=1.0`.
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub struct UnitInterval;
32
33impl FloatBounds for UnitInterval {
34    const MIN: Option<f64> = Some(0.0);
35    const MAX: Option<f64> = Some(1.0);
36}
37
38/// Non-negative floats (`>= 0.0`).
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub struct NonNegative;
41
42impl FloatBounds for NonNegative {
43    const MIN: Option<f64> = Some(0.0);
44    const MAX: Option<f64> = None;
45}
46
47/// Strictly positive floats (`> 0.0`).
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub struct Positive;
50
51impl FloatBounds for Positive {
52    const MIN: Option<f64> = Some(0.0);
53    const MAX: Option<f64> = None;
54    const EXCLUSIVE_MIN: bool = true;
55}
56
57/// Finite `f64` constrained by `B`.
58///
59/// NaN and infinities are rejected (`finite_number`). Bound errors reuse
60/// `greater_than` / `greater_than_equal` / `less_than` / `less_than_equal`.
61///
62/// Pydantic `confloat` takes numeric bounds as arguments; here they live on
63/// a [`FloatBounds`] type because stable Rust has no `f64` const generics.
64#[derive(Debug, PartialEq, PartialOrd)]
65pub struct BoundedFloat<B>(f64, PhantomData<fn() -> B>);
66
67impl<B> Clone for BoundedFloat<B> {
68    fn clone(&self) -> Self {
69        *self
70    }
71}
72
73impl<B> Copy for BoundedFloat<B> {}
74
75impl<B: FloatBounds> BoundedFloat<B> {
76    /// Validate finiteness and bounds, then wrap `value`.
77    pub fn new(value: f64) -> Result<Self, FieldError> {
78        if !value.is_finite() {
79            return Err(FieldError::new("finite_number", "expected a finite number"));
80        }
81        if let Some(min) = B::MIN {
82            if B::EXCLUSIVE_MIN {
83                rules::gt(&value, &min)?;
84            } else {
85                rules::ge(&value, &min)?;
86            }
87        }
88        if let Some(max) = B::MAX {
89            if B::EXCLUSIVE_MAX {
90                rules::lt(&value, &max)?;
91            } else {
92                rules::le(&value, &max)?;
93            }
94        }
95        Ok(Self(value, PhantomData))
96    }
97}
98
99impl<B> BoundedFloat<B> {
100    /// Unwrap the inner float.
101    pub fn into_inner(self) -> f64 {
102        self.0
103    }
104}
105
106impl<B> AsRef<f64> for BoundedFloat<B> {
107    fn as_ref(&self) -> &f64 {
108        &self.0
109    }
110}
111
112impl<B> std::ops::Deref for BoundedFloat<B> {
113    type Target = f64;
114
115    fn deref(&self) -> &Self::Target {
116        &self.0
117    }
118}
119
120impl<B> std::fmt::Display for BoundedFloat<B> {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        self.0.fmt(f)
123    }
124}
125
126impl<B> From<BoundedFloat<B>> for f64 {
127    fn from(value: BoundedFloat<B>) -> Self {
128        value.0
129    }
130}
131
132impl<B: FloatBounds> TryFrom<f64> for BoundedFloat<B> {
133    type Error = FieldError;
134
135    fn try_from(value: f64) -> Result<Self, Self::Error> {
136        Self::new(value)
137    }
138}
139
140impl<B> Serialize for BoundedFloat<B> {
141    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
142    where
143        S: Serializer,
144    {
145        self.0.serialize(serializer)
146    }
147}
148
149impl<'de, B: FloatBounds> Deserialize<'de> for BoundedFloat<B> {
150    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
151    where
152        D: Deserializer<'de>,
153    {
154        let value = f64::deserialize(deserializer)?;
155        Self::new(value).map_err(D::Error::custom)
156    }
157}
158
159impl<B: FloatBounds> Validate for BoundedFloat<B> {
160    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
161        let before = ctx.error_count();
162        f64::prepare(input, ctx);
163        if ctx.error_count() != before {
164            return;
165        }
166        let Some(value) = input.as_f64() else {
167            return;
168        };
169        ctx.check(Self::new(value).map(|_| ()));
170    }
171}
172
173fn f64_schema_value(value: f64) -> Value {
174    Number::from_f64(value).map_or(Value::Null, Value::Number)
175}
176
177impl<B: FloatBounds> Schema for BoundedFloat<B> {
178    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
179        let mut schema = SchemaObject::of_type("number").with("format", "double");
180        if let Some(min) = B::MIN {
181            let key = if B::EXCLUSIVE_MIN {
182                "exclusiveMinimum"
183            } else {
184                "minimum"
185            };
186            schema = schema.with(key, f64_schema_value(min));
187        }
188        if let Some(max) = B::MAX {
189            let key = if B::EXCLUSIVE_MAX {
190                "exclusiveMaximum"
191            } else {
192                "maximum"
193            };
194            schema = schema.with(key, f64_schema_value(max));
195        }
196        schema
197    }
198}
199
200impl<B: FloatBounds> Dump for BoundedFloat<B> {}
201
202#[cfg(test)]
203mod tests {
204    #![allow(clippy::unwrap_used)]
205
206    use super::*;
207    use crate::context::ValidationContext;
208    use crate::dump::{Dump, DumpOptions};
209    use crate::schema::schema_for;
210    use crate::types::prepare_codes;
211    use serde_json::json;
212
213    type Unit = BoundedFloat<UnitInterval>;
214    type Pos = BoundedFloat<Positive>;
215    type NonNeg = BoundedFloat<NonNegative>;
216
217    #[test]
218    fn bounded_float_construction() {
219        assert_eq!(Unit::new(0.0).unwrap().into_inner(), 0.0);
220        assert_eq!(Unit::new(1.0).unwrap().into_inner(), 1.0);
221        assert_eq!(Unit::new(-0.1).unwrap_err().code, "greater_than_equal");
222        assert_eq!(Unit::new(1.1).unwrap_err().code, "less_than_equal");
223        assert_eq!(Pos::new(0.0).unwrap_err().code, "greater_than");
224        assert!(Pos::new(0.1).is_ok());
225        assert!(NonNeg::new(0.0).is_ok());
226        assert_eq!(Unit::new(f64::NAN).unwrap_err().code, "finite_number");
227        assert_eq!(Unit::new(f64::INFINITY).unwrap_err().code, "finite_number");
228    }
229
230    #[test]
231    fn bounded_float_serde_round_trip_and_reject() {
232        let value = Unit::new(0.5).unwrap();
233        let encoded = serde_json::to_string(&value).unwrap();
234        assert_eq!(encoded, "0.5");
235        let round: Unit = serde_json::from_str(&encoded).unwrap();
236        assert_eq!(*round, 0.5);
237        assert!(serde_json::from_str::<Unit>("1.5").is_err());
238        assert!(serde_json::from_str::<Pos>("0").is_err());
239    }
240
241    #[test]
242    fn bounded_float_prepare_lax_and_strict() {
243        assert_eq!(
244            prepare_codes::<Unit>(json!("0.25"), ValidationContext::new()),
245            (json!(0.25), vec![])
246        );
247        assert_eq!(
248            prepare_codes::<Unit>(json!("0.25"), ValidationContext::new().with_strict(true)).1,
249            ["float_type"]
250        );
251        assert_eq!(
252            prepare_codes::<Unit>(json!(1.5), ValidationContext::new()).1,
253            ["less_than_equal"]
254        );
255        assert_eq!(
256            prepare_codes::<Pos>(json!(0.0), ValidationContext::new()).1,
257            ["greater_than"]
258        );
259        assert_eq!(
260            prepare_codes::<Unit>(json!("nan"), ValidationContext::new()).1,
261            ["float_parsing"]
262        );
263    }
264
265    #[test]
266    fn bounded_float_schema_dump_and_parse_value() {
267        let unit = schema_for::<Unit>().0.into_value();
268        assert_eq!(unit["type"], json!("number"));
269        assert_eq!(unit["format"], json!("double"));
270        assert_eq!(unit["minimum"], json!(0.0));
271        assert_eq!(unit["maximum"], json!(1.0));
272
273        let pos = schema_for::<Pos>().0.into_value();
274        assert_eq!(pos["exclusiveMinimum"], json!(0.0));
275        assert!(pos.get("minimum").is_none());
276
277        let value = Unit::new(0.5).unwrap();
278        assert_eq!(value.dump(&DumpOptions::new()).unwrap(), json!(0.5));
279        let parsed = crate::parse_value::<Unit>(json!("0.5"), ValidationContext::new()).unwrap();
280        assert_eq!(*parsed, 0.5);
281        let err = crate::parse_value::<Unit>(json!(2.0), ValidationContext::new()).unwrap_err();
282        assert_eq!(err.errors[0].code, "less_than_equal");
283    }
284}