const_decimal/
display.rs

1use std::cmp::Ordering;
2use std::fmt::Display;
3use std::num::ParseIntError;
4use std::str::FromStr;
5
6use thiserror::Error;
7
8use crate::{Decimal, ScaledInteger};
9
10impl<I, const D: u8> Display for Decimal<I, D>
11where
12    I: ScaledInteger<D>,
13{
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        let (sign, unsigned) = match self.0 < I::ZERO {
16            // NB: Integers do not implement negation, so lets use two's complement to flip the sign
17            // of the signed integer (modelled as an unsigned integer).
18            true => ("-", (!self.0).wrapping_add(&I::ONE)),
19            false => ("", self.0),
20        };
21        // `SCALING_FACTOR` cannot be zero.
22        #[allow(clippy::arithmetic_side_effects)]
23        let integer = unsigned / I::SCALING_FACTOR;
24        // `SCALING_FACTOR` cannot be zero.
25        #[allow(clippy::arithmetic_side_effects)]
26        let fractional = unsigned % I::SCALING_FACTOR;
27
28        write!(f, "{sign}{integer}.{fractional:0>decimals$}", decimals = D as usize)
29    }
30}
31
32impl<I, const D: u8> FromStr for Decimal<I, D>
33where
34    I: ScaledInteger<D>,
35{
36    type Err = ParseDecimalError<I>;
37
38    fn from_str(s: &str) -> Result<Self, Self::Err> {
39        // Strip the sign (-0 would parse to 0 and break our output).
40        let unsigned_s = s.strip_prefix('-').unwrap_or(s);
41
42        // Parse the unsigned representation.
43        let (integer_s, fractional_s) = unsigned_s
44            .split_once('.')
45            .ok_or(ParseDecimalError::MissingDecimalPoint)?;
46        let integer = I::from_str(integer_s)?;
47        let fractional = I::from_str(fractional_s)?;
48
49        let scaled_integer = integer
50            .checked_mul(&I::SCALING_FACTOR)
51            .ok_or(ParseDecimalError::Overflow(integer, fractional))?;
52
53        let fractional_s_len = fractional_s.len();
54        let fractional = match fractional_s_len.cmp(&(D as usize)) {
55            Ordering::Equal => fractional,
56            Ordering::Less => {
57                // `fractional_s_len` guaranteed to be less than D.
58                #[allow(clippy::arithmetic_side_effects)]
59                let shortfall = D as usize - fractional_s_len;
60
61                // TODO: Remove the `checked_mul` in favor of ensuring `D` cannot overflow.
62                fractional
63                    .checked_mul(&I::pow(I::TEN, shortfall.try_into().unwrap()))
64                    .unwrap()
65            }
66            Ordering::Greater => return Err(ParseDecimalError::PrecisionLoss(fractional_s.len())),
67        };
68        let unsigned = scaled_integer
69            .checked_add(&fractional)
70            .ok_or(ParseDecimalError::Overflow(integer, fractional))?;
71
72        // Use two's complement to convert to the signed representation.
73        Ok(match unsigned_s.len() == s.len() {
74            true => Decimal(unsigned),
75            false => {
76                debug_assert_eq!(unsigned_s.len().checked_add(1).unwrap(), s.len());
77
78                Decimal((!unsigned).wrapping_add(&I::ONE))
79            }
80        })
81    }
82}
83
84#[derive(Debug, PartialEq, Eq, Error)]
85pub enum ParseDecimalError<I>
86where
87    I: Display,
88{
89    #[error("Missing decimal point")]
90    MissingDecimalPoint,
91    #[error("Resultant decimal overflowed; integer={0}; fractional={1}")]
92    Overflow(I, I),
93    #[error("Failed to parse integer; err={0}")]
94    ParseInt(#[from] ParseIntError),
95    #[error("Could not parse without precision loss; decimals={0}")]
96    PrecisionLoss(usize),
97}
98
99#[cfg(test)]
100mod tests {
101    use expect_test::expect;
102    use proptest::prelude::Arbitrary;
103    use proptest::proptest;
104    use proptest::test_runner::TestRunner;
105
106    use super::*;
107    use crate::{Int64_9, Uint64_9};
108
109    #[test]
110    fn uint64_9_to_string() {
111        assert_eq!(Uint64_9::ONE.to_string(), "1.000000000");
112        assert_eq!(Uint64_9::try_from_scaled(123, 9).unwrap().to_string(), "0.000000123");
113        assert_eq!(
114            (Uint64_9::ONE + Uint64_9::try_from_scaled(123, 9).unwrap()).to_string(),
115            "1.000000123"
116        );
117    }
118
119    #[test]
120    fn uint64_9_from_str() {
121        assert_eq!("".parse::<Uint64_9>(), Err(ParseDecimalError::MissingDecimalPoint));
122        expect![[r#"
123            Err(
124                ParseInt(
125                    ParseIntError {
126                        kind: Empty,
127                    },
128                ),
129            )
130        "#]]
131        .assert_debug_eq(&"1.".parse::<Uint64_9>());
132        assert_eq!("1.0".parse::<Uint64_9>(), Ok(Uint64_9::ONE));
133        assert_eq!("0.1".parse::<Uint64_9>(), Ok(Decimal(10u64.pow(8))));
134        assert_eq!("0.123456789".parse::<Uint64_9>(), Ok(Decimal(123456789)));
135        assert_eq!("0.012345678".parse::<Uint64_9>(), Ok(Decimal(12345678)));
136        assert_eq!("0.000000001".parse::<Uint64_9>(), Ok(Decimal(1)));
137
138        assert_eq!("0.0000000001".parse::<Uint64_9>(), Err(ParseDecimalError::PrecisionLoss(10)));
139        assert_eq!(
140            format!("{}.0", u64::MAX).parse::<Uint64_9>(),
141            Err(ParseDecimalError::Overflow(u64::MAX, 0))
142        );
143        assert_eq!(
144            format!("{}.0", u64::MAX / Uint64_9::SCALING_FACTOR).parse::<Uint64_9>(),
145            Ok(Decimal(u64::MAX / Uint64_9::SCALING_FACTOR * Uint64_9::SCALING_FACTOR))
146        );
147        assert_eq!(format!("18446744073.709551615").parse::<Uint64_9>(), Ok(Decimal::max()),);
148        assert_eq!(
149            format!("18446744073.709551616").parse::<Uint64_9>(),
150            Err(ParseDecimalError::Overflow(18446744073, 709551616)),
151        );
152    }
153
154    #[test]
155    fn int64_9_to_string() {
156        assert_eq!(Int64_9::ZERO.to_string(), "0.000000000");
157        assert_eq!(Int64_9::ONE.to_string(), "1.000000000");
158        assert_eq!(Int64_9::try_from_scaled(123, 9).unwrap().to_string(), "0.000000123");
159        assert_eq!(
160            (Int64_9::ONE + Int64_9::try_from_scaled(123, 9).unwrap()).to_string(),
161            "1.000000123"
162        );
163        assert_eq!((-Int64_9::ONE).to_string(), "-1.000000000");
164        assert_eq!((-Int64_9::try_from_scaled(123, 9).unwrap()).to_string(), "-0.000000123");
165        assert_eq!(
166            (-Int64_9::ONE + -Int64_9::try_from_scaled(123, 9).unwrap()).to_string(),
167            "-1.000000123"
168        );
169    }
170
171    #[test]
172    fn int64_9_from_str() {
173        assert_eq!("".parse::<Int64_9>(), Err(ParseDecimalError::MissingDecimalPoint));
174        expect![[r#"
175            Err(
176                ParseInt(
177                    ParseIntError {
178                        kind: Empty,
179                    },
180                ),
181            )
182        "#]]
183        .assert_debug_eq(&"1.".parse::<Int64_9>());
184        assert_eq!("1.0".parse::<Int64_9>(), Ok(Int64_9::ONE));
185        assert_eq!("0.1".parse::<Int64_9>(), Ok(Decimal(10i64.pow(8))));
186        assert_eq!("0.123456789".parse::<Int64_9>(), Ok(Decimal(123456789)));
187        assert_eq!("0.012345678".parse::<Int64_9>(), Ok(Decimal(12345678)));
188        assert_eq!("0.000000001".parse::<Int64_9>(), Ok(Decimal(1)));
189        assert_eq!("0.0000000001".parse::<Int64_9>(), Err(ParseDecimalError::PrecisionLoss(10)));
190        assert_eq!("-1.0".parse::<Int64_9>(), Ok(-Int64_9::ONE));
191        assert_eq!("-0.1".parse::<Int64_9>(), Ok(-Decimal(10i64.pow(8))));
192        assert_eq!("-0.123456789".parse::<Int64_9>(), Ok(-Decimal(123456789)));
193        assert_eq!("-0.012345678".parse::<Int64_9>(), Ok(-Decimal(12345678)));
194        assert_eq!("-0.000000001".parse::<Int64_9>(), Ok(-Decimal(1)));
195        assert_eq!("-0.0000000001".parse::<Int64_9>(), Err(ParseDecimalError::PrecisionLoss(10)));
196    }
197
198    // TODO: Round trip fuzz test does not cover strings with precision greater/less
199    // than target precision.
200
201    #[test]
202    fn uint64_9_round_trip() {
203        decimal_round_trip::<9, u64>();
204    }
205
206    #[test]
207    fn int64_9_round_trip() {
208        decimal_round_trip::<9, i64>();
209    }
210
211    #[test]
212    fn uint128_18_round_trip() {
213        decimal_round_trip::<9, u64>();
214    }
215
216    #[test]
217    fn int128_18_round_trip() {
218        decimal_round_trip::<9, i64>();
219    }
220
221    fn decimal_round_trip<const D: u8, I>()
222    where
223        I: ScaledInteger<D> + Arbitrary,
224    {
225        let mut runner = TestRunner::default();
226        let input = Decimal::arbitrary();
227
228        runner
229            .run(&input, |decimal: Decimal<I, D>| {
230                let round_trip = decimal.to_string().parse().unwrap();
231
232                assert_eq!(decimal, round_trip);
233
234                Ok(())
235            })
236            .unwrap();
237    }
238
239    #[test]
240    fn uint64_9_parse_no_panic() {
241        decimal_parse_no_panic::<9, u64>();
242    }
243
244    #[test]
245    fn int64_9_parse_no_panic() {
246        decimal_parse_no_panic::<9, i64>();
247    }
248
249    #[test]
250    fn uint128_18_parse_no_panic() {
251        decimal_parse_no_panic::<9, u64>();
252    }
253
254    #[test]
255    fn int128_18_parse_no_panic() {
256        decimal_parse_no_panic::<9, i64>();
257    }
258
259    fn decimal_parse_no_panic<const D: u8, I>()
260    where
261        I: ScaledInteger<D>,
262    {
263        proptest!(|(decimal_s: String)| {
264            let _ = decimal_s.parse::<Decimal<I, D>>();
265        });
266    }
267
268    #[test]
269    fn uint64_9_parse_numeric_no_panic() {
270        decimal_parse_numeric_no_panic::<9, u64>();
271    }
272
273    #[test]
274    fn int64_9_parse_numeric_no_panic() {
275        decimal_parse_numeric_no_panic::<9, i64>();
276    }
277
278    #[test]
279    fn uint128_18_parse_numeric_no_panic() {
280        decimal_parse_numeric_no_panic::<9, u64>();
281    }
282
283    #[test]
284    fn int128_18_parse_numeric_no_panic() {
285        decimal_parse_numeric_no_panic::<9, i64>();
286    }
287
288    fn decimal_parse_numeric_no_panic<const D: u8, I>()
289    where
290        I: ScaledInteger<D>,
291    {
292        proptest!(|(decimal_s in "[0-9]{0,24}\\.[0-9]{0,24}")| {
293            let _ = decimal_s.parse::<Decimal<I, D>>();
294        });
295    }
296}