Skip to main content

librotenado/
decimal.rs

1// SPDX-License-Identifier: MIT
2//! Exact decimal numbers for prices, quantities and rates – never a float.
3
4use std::{cmp::Ordering, fmt, ops::Neg, str::FromStr};
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7
8use crate::ParseError;
9
10/// An exact decimal number `units · 10^−scale` – a price per unit, a quantity, a rate. Money is [`crate::Money`].
11///
12/// A value has one form only: the smallest scale (`units` not divisible by ten unless the scale is 0), written as
13/// a string in exactly one way (`"1.5"`, never `"1.50"`; `"0"`, never `"-0"`). Reading rejects any other form, so
14/// the same value always has the same bytes and the same hash. Addition and subtraction are exact; rounding is a
15/// rule of the law that applies, not of the protocol.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub struct Decimal {
18    units: i128,
19    scale: u8,
20}
21
22impl Decimal {
23    /// The most decimal places a number may have.
24    pub const MAX_SCALE: u8 = 18;
25    /// Zero.
26    pub const ZERO: Decimal = Decimal { units: 0, scale: 0 };
27
28    /// `units · 10^−scale`, reduced to its one form; `None` if the scale is above [`Self::MAX_SCALE`].
29    pub fn new(units: i128, scale: u8) -> Option<Self> {
30        (scale <= Self::MAX_SCALE).then(|| Decimal { units, scale }.reduced())
31    }
32
33    /// The number of decimal places.
34    pub fn scale(self) -> u8 {
35        self.scale
36    }
37
38    /// Whether the value is zero.
39    pub fn is_zero(self) -> bool {
40        self.units == 0
41    }
42
43    /// Whether the value is below zero.
44    pub fn is_negative(self) -> bool {
45        self.units < 0
46    }
47
48    /// The exact sum, or `None` on overflow.
49    pub fn checked_add(self, other: Self) -> Option<Self> {
50        let scale = self.scale.max(other.scale);
51        let a = self.units_at(scale)?;
52        let b = other.units_at(scale)?;
53        Some(Decimal { units: a.checked_add(b)?, scale }.reduced())
54    }
55
56    /// The exact difference, or `None` on overflow.
57    pub fn checked_sub(self, other: Self) -> Option<Self> {
58        self.checked_add(-other)
59    }
60
61    /// The units at a scale at least as large as the own one.
62    fn units_at(self, scale: u8) -> Option<i128> {
63        10i128.checked_pow(u32::from(scale - self.scale))?.checked_mul(self.units)
64    }
65
66    /// The same value at the smallest scale.
67    fn reduced(self) -> Self {
68        let (mut units, mut scale) = (self.units, self.scale);
69        while scale > 0 && units % 10 == 0 {
70            units /= 10;
71            scale -= 1;
72        }
73        Decimal { units, scale }
74    }
75}
76
77impl Neg for Decimal {
78    type Output = Decimal;
79
80    /// The same value with the opposite sign.
81    fn neg(self) -> Decimal {
82        Decimal { units: -self.units, scale: self.scale }
83    }
84}
85
86impl Ord for Decimal {
87    fn cmp(&self, other: &Self) -> Ordering {
88        let scale = self.scale.max(other.scale);
89        match (self.units_at(scale), other.units_at(scale)) {
90            (Some(a), Some(b)) => a.cmp(&b),
91            // Only reachable near the limits of i128: compare the integer parts, then the fractions.
92            _ => (self.units / 10i128.pow(u32::from(self.scale)))
93                .cmp(&(other.units / 10i128.pow(u32::from(other.scale))))
94                .then_with(|| (self.units, self.scale).cmp(&(other.units, other.scale))),
95        }
96    }
97}
98
99impl PartialOrd for Decimal {
100    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
101        Some(self.cmp(other))
102    }
103}
104
105impl FromStr for Decimal {
106    type Err = ParseError;
107
108    fn from_str(s: &str) -> Result<Self, Self::Err> {
109        let err = || ParseError::Decimal(s.to_owned());
110        let (negative, digits) = match s.strip_prefix('-') {
111            Some(rest) => (true, rest),
112            None => (false, s),
113        };
114        let (int, frac) = digits.split_once('.').unwrap_or((digits, ""));
115        let valid = |part: &str| part.bytes().all(|b| b.is_ascii_digit());
116        // The one form: no leading zero in the integer part, no trailing zero in the places, no "-0".
117        let canonical = (int == "0" || !int.starts_with('0'))
118            && !frac.ends_with('0')
119            && (digits.contains('.') == !frac.is_empty())
120            && !(negative && int == "0" && frac.is_empty());
121        if int.is_empty() || !valid(int) || !valid(frac) || !canonical {
122            return Err(err());
123        }
124        let scale = u8::try_from(frac.len()).ok().filter(|&n| n <= Self::MAX_SCALE).ok_or_else(err)?;
125        let units: i128 = format!("{int}{frac}").parse().map_err(|_| err())?;
126        Ok(Decimal { units: if negative { -units } else { units }, scale })
127    }
128}
129
130impl fmt::Display for Decimal {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        let sign = if self.units < 0 { "-" } else { "" };
133        let digits = self.units.unsigned_abs().to_string();
134        let scale = usize::from(self.scale);
135        if scale == 0 {
136            return write!(f, "{sign}{digits}");
137        }
138        let digits = format!("{digits:0>width$}", width = scale + 1);
139        let (int, frac) = digits.split_at(digits.len() - scale);
140        write!(f, "{sign}{int}.{frac}")
141    }
142}
143
144impl Serialize for Decimal {
145    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
146        serializer.collect_str(self)
147    }
148}
149
150impl<'de> Deserialize<'de> for Decimal {
151    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
152        String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn d(s: &str) -> Decimal {
161        s.parse().unwrap()
162    }
163
164    #[test]
165    fn parses_and_prints_its_one_form() {
166        for s in ["0", "12", "100", "-12.3", "0.05", "-0.5", "1.799", "1234567.891"] {
167            assert_eq!(d(s).to_string(), s);
168        }
169    }
170
171    #[test]
172    fn rejects_every_other_form() {
173        for s in ["", "-", ".5", "5.", "1e3", "+1", "1,5", " 1", "0.1234567890123456789"] {
174            assert!(s.parse::<Decimal>().is_err(), "{s:?}");
175        }
176        for s in ["1.50", "1.0", "01.5", "00", "-0", "-0.0", "0.10"] {
177            assert!(s.parse::<Decimal>().is_err(), "{s:?} is not in its one form");
178        }
179    }
180
181    #[test]
182    fn construction_reduces_to_the_one_form() {
183        assert_eq!(Decimal::new(150, 2).unwrap(), d("1.5"));
184        assert_eq!(Decimal::new(150, 2).unwrap().scale(), 1);
185        assert_eq!(Decimal::new(0, 5).unwrap(), Decimal::ZERO);
186        assert_eq!(-Decimal::ZERO, Decimal::ZERO);
187    }
188
189    #[test]
190    fn compares_by_value() {
191        assert!(d("-0.01") < d("0"));
192        assert!(d("2") > d("1.99"));
193    }
194
195    #[test]
196    fn adds_and_subtracts_exactly() {
197        assert_eq!(d("0.1").checked_add(d("0.2")).unwrap(), d("0.3"));
198        assert_eq!(d("0.25").checked_add(d("0.25")).unwrap().to_string(), "0.5");
199        assert_eq!(d("10").checked_sub(d("0.01")).unwrap().to_string(), "9.99");
200        assert!(Decimal::new(i128::MAX, 0).unwrap().checked_add(d("1")).is_none());
201    }
202
203    #[test]
204    fn is_a_string_in_json() {
205        assert_eq!(serde_json::to_string(&d("-12.3")).unwrap(), r#""-12.3""#);
206        assert_eq!(serde_json::from_str::<Decimal>(r#""4.2""#).unwrap(), d("4.2"));
207        assert!(serde_json::from_str::<Decimal>(r#""4.20""#).is_err());
208        assert!(serde_json::from_str::<Decimal>("4.2").is_err());
209    }
210}