icydb_base/sanitizer/
num.rs

1use crate::{
2    core::traits::{NumCast, Sanitizer},
3    prelude::*,
4};
5
6///
7/// Clamp
8///
9
10#[sanitizer]
11pub struct Clamp {
12    min: Decimal,
13    max: Decimal,
14}
15
16impl Clamp {
17    pub fn new<N: NumCast>(min: N, max: N) -> Self {
18        let min = <Decimal as NumCast>::from(min).unwrap();
19        let max = <Decimal as NumCast>::from(max).unwrap();
20        assert!(min <= max, "clamp requires min <= max");
21
22        Self { min, max }
23    }
24}
25
26impl<T: NumCast + Clone> Sanitizer<T> for Clamp {
27    fn sanitize(&self, value: T) -> T {
28        let v = <Decimal as NumCast>::from(value).unwrap();
29
30        let clamped = if v < self.min {
31            self.min
32        } else if v > self.max {
33            self.max
34        } else {
35            v
36        };
37
38        // Convert clamped Decimal back into original type N
39        <T as NumCast>::from(clamped).expect("clamped value must fit into target type")
40    }
41}
42
43///
44/// RoundDecimalPlaces
45///
46/// Rounds a `Decimal` to a fixed number of decimal places.
47/// Defaults to midpoint-away-from-zero rounding, which is friendlier for
48/// currency-style values than bankers rounding.
49///
50
51#[sanitizer]
52pub struct RoundDecimalPlaces {
53    scale: u32,
54}
55
56impl RoundDecimalPlaces {
57    #[must_use]
58    pub const fn new(scale: u32) -> Self {
59        Self { scale }
60    }
61}
62
63impl Sanitizer<Decimal> for RoundDecimalPlaces {
64    fn sanitize(&self, value: Decimal) -> Decimal {
65        value.round_dp(self.scale)
66    }
67}
68
69///
70/// TESTS
71///
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use std::str::FromStr;
77
78    #[test]
79    fn clamps_integers() {
80        let clamp = Clamp::new(10, 20);
81
82        assert_eq!(clamp.sanitize(5), 10, "below min should clamp to min");
83        assert_eq!(clamp.sanitize(25), 20, "above max should clamp to max");
84        assert_eq!(clamp.sanitize(15), 15, "within range should stay the same");
85        assert_eq!(clamp.sanitize(10), 10, "exact min should stay the same");
86        assert_eq!(clamp.sanitize(20), 20, "exact max should stay the same");
87    }
88
89    #[test]
90    fn handles_edge_cases() {
91        let clamp = Clamp::new(-10, -5);
92
93        assert_eq!(clamp.sanitize(-20), -10, "below min clamps to min");
94        assert_eq!(clamp.sanitize(-7), -7, "within range is untouched");
95        assert_eq!(clamp.sanitize(-5), -5, "exact max stays");
96        assert_eq!(clamp.sanitize(0), -5, "above max clamps to max");
97    }
98
99    #[test]
100    fn rounds_decimal_places_midpoint_away_from_zero() {
101        let round = RoundDecimalPlaces::new(2);
102
103        assert_eq!(
104            round.sanitize(Decimal::from_str("1.234").unwrap()),
105            Decimal::from_str("1.23").unwrap(),
106            "should round down when below midpoint"
107        );
108        assert_eq!(
109            round.sanitize(Decimal::from_str("1.235").unwrap()),
110            Decimal::from_str("1.24").unwrap(),
111            "should round up at midpoint away from zero"
112        );
113        assert_eq!(
114            round.sanitize(Decimal::from_str("-1.235").unwrap()),
115            Decimal::from_str("-1.24").unwrap(),
116            "negative midpoint should round away from zero"
117        );
118    }
119}