Skip to main content

paft_decimal/
lib.rs

1//! Backend-agnostic decimal helpers shared across the `paft` workspace.
2//!
3//! The crate wraps [`rust_decimal`](https://docs.rs/rust_decimal) by default and can
4//! switch to [`bigdecimal`](https://docs.rs/bigdecimal) via the optional
5//! `bigdecimal` feature. It exposes a consistent [`Decimal`] type alongside rounding
6//! strategies and utility helpers for parsing, scaling, and canonical rendering
7//! without pulling in higher-level money abstractions.
8
9#![cfg_attr(docsrs, feature(doc_cfg))]
10#![forbid(unsafe_code)]
11#![warn(missing_docs)]
12
13use std::{borrow::Cow, str::FromStr};
14
15mod constrained;
16
17pub use constrained::{DecimalConstraintError, NonNegativeDecimal, PositiveDecimal, Ratio};
18
19/// Rounding strategy supported by decimal operations.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum RoundingStrategy {
23    /// Round halves toward the nearest even digit.
24    MidpointNearestEven,
25    /// Round halves away from zero.
26    MidpointAwayFromZero,
27    /// Round halves toward zero.
28    MidpointTowardZero,
29    /// Always round toward zero.
30    ToZero,
31    /// Always round away from zero.
32    AwayFromZero,
33    /// Always round toward negative infinity.
34    ToNegativeInfinity,
35    /// Always round toward positive infinity.
36    ToPositiveInfinity,
37}
38
39#[cfg(not(feature = "bigdecimal"))]
40mod backend {
41    use super::{
42        FromStr, RoundingStrategy, rust_decimal_to_i128_mantissa, rust_decimal_to_scaled_units,
43    };
44
45    pub use rust_decimal::Decimal;
46    use rust_decimal::RoundingStrategy as RustRoundingStrategy;
47    pub use rust_decimal::prelude::ToPrimitive;
48
49    pub fn parse_decimal(value: &str) -> Option<Decimal> {
50        Decimal::from_str(value).ok()
51    }
52
53    pub const MAX_DECIMAL_PRECISION: u8 = 28;
54
55    pub const fn clone_decimal(value: &Decimal) -> Decimal {
56        *value
57    }
58
59    pub fn fractional_digit_count(value: &Decimal) -> i64 {
60        i64::from(value.scale())
61    }
62
63    pub const fn zero() -> Decimal {
64        Decimal::ZERO
65    }
66
67    pub const fn one() -> Decimal {
68        Decimal::ONE
69    }
70
71    pub fn from_minor_units(value: i128, scale: u32) -> Decimal {
72        Decimal::from_i128_with_scale(value, scale)
73    }
74
75    pub fn try_from_scaled_units(value: i128, scale: u32) -> Option<Decimal> {
76        Decimal::try_from_i128_with_scale(value, scale).ok()
77    }
78
79    pub fn round_dp_with_strategy(
80        value: &Decimal,
81        scale: u32,
82        strategy: RoundingStrategy,
83    ) -> Decimal {
84        let strategy: RustRoundingStrategy = strategy.into();
85        value.round_dp_with_strategy(scale, strategy)
86    }
87
88    pub fn to_plain_string(value: &Decimal) -> String {
89        value.to_string()
90    }
91
92    pub fn checked_add(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
93        lhs.checked_add(*rhs)
94    }
95
96    pub fn checked_sub(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
97        lhs.checked_sub(*rhs)
98    }
99
100    pub fn checked_mul(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
101        lhs.checked_mul(*rhs)
102    }
103
104    pub fn checked_div(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
105        lhs.checked_div(*rhs)
106    }
107
108    pub fn try_to_scaled_units(value: &Decimal, target_scale: u32) -> Option<i128> {
109        rust_decimal_to_scaled_units(value, target_scale)
110    }
111
112    pub fn try_to_i128_mantissa(value: &Decimal, target_scale: u32) -> Option<i128> {
113        rust_decimal_to_i128_mantissa(value, target_scale)
114    }
115
116    impl From<RoundingStrategy> for RustRoundingStrategy {
117        fn from(value: RoundingStrategy) -> Self {
118            match value {
119                RoundingStrategy::MidpointNearestEven => Self::MidpointNearestEven,
120                RoundingStrategy::MidpointAwayFromZero => Self::MidpointAwayFromZero,
121                RoundingStrategy::MidpointTowardZero => Self::MidpointTowardZero,
122                RoundingStrategy::ToZero => Self::ToZero,
123                RoundingStrategy::AwayFromZero => Self::AwayFromZero,
124                RoundingStrategy::ToNegativeInfinity => Self::ToNegativeInfinity,
125                RoundingStrategy::ToPositiveInfinity => Self::ToPositiveInfinity,
126            }
127        }
128    }
129}
130
131#[cfg(feature = "bigdecimal")]
132mod backend {
133    use super::{DECIMAL128_PRECISION, FromStr, RoundingStrategy};
134
135    pub use bigdecimal::BigDecimal as Decimal;
136    use bigdecimal::RoundingMode;
137    use num_bigint::BigInt;
138    pub use num_traits::ToPrimitive;
139    use num_traits::{One, Zero};
140
141    pub fn parse_decimal(value: &str) -> Option<Decimal> {
142        Decimal::from_str(value).ok()
143    }
144
145    pub const MAX_DECIMAL_PRECISION: u8 = u8::MAX;
146
147    pub fn clone_decimal(value: &Decimal) -> Decimal {
148        value.clone()
149    }
150
151    pub fn fractional_digit_count(value: &Decimal) -> i64 {
152        value.fractional_digit_count()
153    }
154
155    pub fn zero() -> Decimal {
156        Decimal::zero()
157    }
158
159    pub fn one() -> Decimal {
160        Decimal::one()
161    }
162
163    pub fn from_minor_units(value: i128, scale: u32) -> Decimal {
164        Decimal::new(BigInt::from(value), i64::from(scale))
165    }
166
167    #[expect(
168        clippy::unnecessary_wraps,
169        reason = "bigdecimal accepts every i128 coefficient and u32 scale, but the backend API mirrors rust_decimal"
170    )]
171    pub fn try_from_scaled_units(value: i128, scale: u32) -> Option<Decimal> {
172        Some(Decimal::new(BigInt::from(value), i64::from(scale)))
173    }
174
175    pub fn round_dp_with_strategy(
176        value: &Decimal,
177        scale: u32,
178        strategy: RoundingStrategy,
179    ) -> Decimal {
180        let mode = match strategy {
181            RoundingStrategy::MidpointNearestEven => RoundingMode::HalfEven,
182            RoundingStrategy::MidpointAwayFromZero => RoundingMode::HalfUp,
183            RoundingStrategy::MidpointTowardZero => RoundingMode::HalfDown,
184            RoundingStrategy::ToZero => RoundingMode::Down,
185            RoundingStrategy::AwayFromZero => RoundingMode::Up,
186            RoundingStrategy::ToNegativeInfinity => RoundingMode::Floor,
187            RoundingStrategy::ToPositiveInfinity => RoundingMode::Ceiling,
188        };
189
190        value.with_scale_round(i64::from(scale), mode)
191    }
192
193    pub fn to_plain_string(value: &Decimal) -> String {
194        value.to_plain_string()
195    }
196
197    #[expect(
198        clippy::unnecessary_wraps,
199        reason = "bigdecimal addition cannot overflow here, but the backend API mirrors rust_decimal checked arithmetic"
200    )]
201    pub fn checked_add(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
202        Some(lhs + rhs)
203    }
204
205    #[expect(
206        clippy::unnecessary_wraps,
207        reason = "bigdecimal subtraction cannot overflow here, but the backend API mirrors rust_decimal checked arithmetic"
208    )]
209    pub fn checked_sub(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
210        Some(lhs - rhs)
211    }
212
213    #[expect(
214        clippy::unnecessary_wraps,
215        reason = "bigdecimal multiplication cannot overflow here, but the backend API mirrors rust_decimal checked arithmetic"
216    )]
217    pub fn checked_mul(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
218        Some(lhs * rhs)
219    }
220
221    pub fn checked_div(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
222        if rhs.is_zero() {
223            return None;
224        }
225
226        Some(lhs / rhs)
227    }
228
229    pub fn try_to_scaled_units(value: &Decimal, target_scale: u32) -> Option<i128> {
230        let (mantissa, source_scale) = value.as_bigint_and_exponent();
231        if mantissa.is_zero() {
232            return Some(0);
233        }
234
235        let target_scale = i64::from(target_scale);
236        let units = match source_scale.cmp(&target_scale) {
237            std::cmp::Ordering::Equal => mantissa,
238            std::cmp::Ordering::Less => {
239                let diff = u32::try_from(target_scale - source_scale).ok()?;
240                mantissa * BigInt::from(10_u8).pow(diff)
241            }
242            std::cmp::Ordering::Greater => {
243                let diff = u32::try_from(source_scale - target_scale).ok()?;
244                let divisor = BigInt::from(10_u8).pow(diff);
245                if (&mantissa % &divisor) != BigInt::zero() {
246                    return None;
247                }
248                mantissa / divisor
249            }
250        };
251
252        i128::try_from(units).ok()
253    }
254
255    pub fn try_to_i128_mantissa(value: &Decimal, target_scale: u32) -> Option<i128> {
256        if target_scale > DECIMAL128_PRECISION {
257            return None;
258        }
259
260        let target = i64::from(target_scale);
261        let rescaled = value.with_scale_round(target, bigdecimal::RoundingMode::HalfEven);
262        if rescaled.digits() > 38 {
263            return None;
264        }
265        let (bigint, _) = rescaled.into_bigint_and_exponent();
266        i128::try_from(bigint).ok()
267    }
268}
269
270pub use backend::{Decimal, ToPrimitive};
271
272const DECIMAL128_PRECISION: u32 = 38;
273const MAX_I128_MANTISSA: i128 = 10_i128.pow(DECIMAL128_PRECISION);
274
275#[cfg(not(feature = "bigdecimal"))]
276fn rust_decimal_to_scaled_units(value: &rust_decimal::Decimal, target_scale: u32) -> Option<i128> {
277    let source_scale = value.scale();
278    let mantissa = value.mantissa();
279    match source_scale.cmp(&target_scale) {
280        std::cmp::Ordering::Equal => Some(mantissa),
281        std::cmp::Ordering::Less => {
282            let diff = target_scale - source_scale;
283            let pow = 10_i128.checked_pow(diff)?;
284            mantissa.checked_mul(pow)
285        }
286        std::cmp::Ordering::Greater => {
287            let diff = source_scale - target_scale;
288            let pow = 10_i128.checked_pow(diff)?;
289            if mantissa % pow != 0 {
290                return None;
291            }
292            Some(mantissa / pow)
293        }
294    }
295}
296
297fn rust_decimal_to_i128_mantissa(value: &rust_decimal::Decimal, target_scale: u32) -> Option<i128> {
298    if target_scale > DECIMAL128_PRECISION {
299        return None;
300    }
301
302    let source_scale = value.scale();
303    let mantissa: i128 = value.mantissa();
304    let rescaled = match source_scale.cmp(&target_scale) {
305        std::cmp::Ordering::Equal => mantissa,
306        std::cmp::Ordering::Less => {
307            let diff = target_scale - source_scale;
308            let pow = 10_i128.checked_pow(diff)?;
309            mantissa.checked_mul(pow)?
310        }
311        std::cmp::Ordering::Greater => {
312            let diff = source_scale - target_scale;
313            let pow = 10_i128.checked_pow(diff)?.cast_unsigned();
314            let neg = mantissa < 0;
315            let abs = mantissa.unsigned_abs();
316            let q = (abs / pow).cast_signed();
317            let r = abs % pow;
318            let half = pow / 2;
319            let rounded = match r.cmp(&half) {
320                std::cmp::Ordering::Greater => q + 1,
321                std::cmp::Ordering::Less => q,
322                std::cmp::Ordering::Equal => q + (q & 1),
323            };
324            if neg { -rounded } else { rounded }
325        }
326    };
327    if rescaled.unsigned_abs() >= MAX_I128_MANTISSA.cast_unsigned() {
328        return None;
329    }
330    Some(rescaled)
331}
332
333/// Maximum fractional precision supported by the active decimal backend.
334pub const MAX_DECIMAL_PRECISION: u8 = backend::MAX_DECIMAL_PRECISION;
335
336/// Returns the maximum fractional precision supported by the active decimal backend.
337#[must_use]
338pub const fn max_decimal_precision() -> u8 {
339    backend::MAX_DECIMAL_PRECISION
340}
341
342/// Clones a decimal value using the active backend's cheapest owned-value path.
343#[must_use]
344#[cfg_attr(
345    not(feature = "bigdecimal"),
346    expect(
347        clippy::missing_const_for_fn,
348        reason = "the public helper stays non-const because bigdecimal cloning is not const"
349    )
350)]
351pub fn clone_decimal(value: &Decimal) -> Decimal {
352    backend::clone_decimal(value)
353}
354
355/// Returns the number of fractional digits represented by the active backend.
356#[must_use]
357pub fn fractional_digit_count(value: &Decimal) -> i64 {
358    backend::fractional_digit_count(value)
359}
360
361/// Adds two decimals if the active backend can represent the result.
362#[must_use]
363pub fn checked_add(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
364    backend::checked_add(lhs, rhs)
365}
366
367/// Subtracts two decimals if the active backend can represent the result.
368#[must_use]
369pub fn checked_sub(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
370    backend::checked_sub(lhs, rhs)
371}
372
373/// Multiplies two decimals if the active backend can represent the result.
374#[must_use]
375pub fn checked_mul(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
376    backend::checked_mul(lhs, rhs)
377}
378
379/// Divides two decimals if the active backend can represent the result.
380#[must_use]
381pub fn checked_div(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
382    backend::checked_div(lhs, rhs)
383}
384
385/// Encodes decimal-like values into Polars-compatible decimal128 mantissas.
386pub trait Decimal128Mantissa {
387    /// Returns the mantissa after rescaling to `target_scale`, or `None` when
388    /// the result exceeds decimal128 precision or the active backend cannot
389    /// represent the conversion.
390    fn try_to_i128_mantissa(&self, target_scale: u32) -> Option<i128>;
391}
392
393impl Decimal128Mantissa for Decimal {
394    fn try_to_i128_mantissa(&self, target_scale: u32) -> Option<i128> {
395        backend::try_to_i128_mantissa(self, target_scale)
396    }
397}
398
399#[cfg(feature = "bigdecimal")]
400impl Decimal128Mantissa for rust_decimal::Decimal {
401    fn try_to_i128_mantissa(&self, target_scale: u32) -> Option<i128> {
402        rust_decimal_to_i128_mantissa(self, target_scale)
403    }
404}
405
406impl Decimal128Mantissa for NonNegativeDecimal {
407    fn try_to_i128_mantissa(&self, target_scale: u32) -> Option<i128> {
408        self.as_decimal().try_to_i128_mantissa(target_scale)
409    }
410}
411
412impl Decimal128Mantissa for PositiveDecimal {
413    fn try_to_i128_mantissa(&self, target_scale: u32) -> Option<i128> {
414        self.as_decimal().try_to_i128_mantissa(target_scale)
415    }
416}
417
418impl Decimal128Mantissa for Ratio {
419    fn try_to_i128_mantissa(&self, target_scale: u32) -> Option<i128> {
420        self.as_decimal().try_to_i128_mantissa(target_scale)
421    }
422}
423
424/// Parses a plain decimal string using the active backend.
425///
426/// Surrounding whitespace is ignored, an optional leading sign is accepted, and
427/// scientific notation, digit separators, and internal whitespace are rejected
428/// so both decimal backends share identical parsing semantics.
429#[must_use]
430pub fn parse_decimal(value: &str) -> Option<Decimal> {
431    let normalized = normalize_decimal_literal(value)?;
432    backend::parse_decimal(&normalized)
433}
434
435fn normalize_decimal_literal(value: &str) -> Option<Cow<'_, str>> {
436    let trimmed = value.trim();
437    if trimmed.is_empty() {
438        return None;
439    }
440
441    let (sign, unsigned) = match trimmed.as_bytes().first() {
442        Some(b'+') => ("", &trimmed[1..]),
443        Some(b'-') => ("-", &trimmed[1..]),
444        Some(_) => ("", trimmed),
445        None => return None,
446    };
447
448    if unsigned.is_empty() {
449        return None;
450    }
451
452    let mut seen_dot = false;
453    let mut seen_digit = false;
454    for byte in unsigned.bytes() {
455        match byte {
456            b'0'..=b'9' => seen_digit = true,
457            b'.' if !seen_dot => seen_dot = true,
458            _ => return None,
459        }
460    }
461
462    if !seen_digit {
463        return None;
464    }
465
466    let needs_leading_zero = unsigned.starts_with('.');
467    let needs_trailing_zero = unsigned.ends_with('.');
468    if needs_leading_zero || needs_trailing_zero {
469        let mut normalized = String::with_capacity(trimmed.len() + 2);
470        normalized.push_str(sign);
471        if needs_leading_zero {
472            normalized.push('0');
473        }
474        normalized.push_str(unsigned);
475        if needs_trailing_zero {
476            normalized.push('0');
477        }
478        Some(Cow::Owned(normalized))
479    } else if sign == "-" {
480        Some(Cow::Borrowed(trimmed))
481    } else {
482        Some(Cow::Borrowed(unsigned))
483    }
484}
485
486/// Returns the zero value for the active decimal backend.
487#[must_use]
488#[cfg_attr(
489    not(feature = "bigdecimal"),
490    expect(
491        clippy::missing_const_for_fn,
492        reason = "the public helper stays non-const because bigdecimal zero construction is not const"
493    )
494)]
495pub fn zero() -> Decimal {
496    backend::zero()
497}
498
499/// Returns the one value for the active decimal backend.
500#[must_use]
501#[cfg_attr(
502    not(feature = "bigdecimal"),
503    expect(
504        clippy::missing_const_for_fn,
505        reason = "the public helper stays non-const because bigdecimal one construction is not const"
506    )
507)]
508pub fn one() -> Decimal {
509    backend::one()
510}
511
512/// Builds a decimal from an integer count of minor units and the provided scale.
513///
514/// # Panics
515///
516/// With the default backend, panics when the scale or integer coefficient cannot
517/// be represented by `rust_decimal`. Use [`try_from_scaled_units`] when the input
518/// is not already known to fit the active backend.
519#[must_use]
520pub fn from_minor_units(value: i128, scale: u32) -> Decimal {
521    backend::from_minor_units(value, scale)
522}
523
524/// Builds a decimal from an integer coefficient and scale if the active backend can represent it.
525///
526/// Returns `None` when the default backend rejects either the scale or the
527/// 96-bit mantissa. The `bigdecimal` backend accepts every `i128` coefficient
528/// and `u32` scale.
529#[must_use]
530pub fn try_from_scaled_units(value: i128, scale: u32) -> Option<Decimal> {
531    backend::try_from_scaled_units(value, scale)
532}
533
534/// Converts a decimal into exact base-10 scaled integer units.
535///
536/// Returns `None` when converting to `target_scale` would require rounding or
537/// when the exact scaled unit count cannot be stored in `i128`.
538#[must_use]
539pub fn try_to_scaled_units(value: &Decimal, target_scale: u32) -> Option<i128> {
540    backend::try_to_scaled_units(value, target_scale)
541}
542
543/// Rounds a decimal to the requested scale using a rounding strategy.
544#[must_use]
545pub fn round_dp_with_strategy(value: &Decimal, scale: u32, strategy: RoundingStrategy) -> Decimal {
546    backend::round_dp_with_strategy(value, scale, strategy)
547}
548
549/// Serde helpers for backend-stable decimal wire formats.
550///
551/// These modules serialize decimals as canonical strings rendered by
552/// [`to_canonical_string`] and deserialize with [`parse_decimal`], avoiding
553/// backend-native differences such as scale preservation or exponent output.
554pub mod serde {
555    use super::{Cow, Decimal};
556    use serde::{Deserialize, Serializer, de};
557
558    fn invalid_decimal<E>(value: &str) -> E
559    where
560        E: de::Error,
561    {
562        E::custom(format_args!("invalid decimal string `{value}`"))
563    }
564
565    /// Serde adapter for a required canonical decimal string.
566    pub mod canonical_str {
567        use super::{Cow, Decimal, Deserialize, Serializer, invalid_decimal};
568        use crate::{parse_decimal, to_canonical_string};
569
570        /// Serializes a decimal as a canonical string.
571        ///
572        /// # Errors
573        /// Returns the serializer error when writing the string fails.
574        pub fn serialize<S>(value: &Decimal, serializer: S) -> Result<S::Ok, S::Error>
575        where
576            S: Serializer,
577        {
578            serializer.serialize_str(&to_canonical_string(value))
579        }
580
581        /// Deserializes a decimal from a string accepted by [`crate::parse_decimal`].
582        ///
583        /// # Errors
584        /// Returns the deserializer error when the input is not a string or
585        /// when [`crate::parse_decimal`] rejects the string.
586        pub fn deserialize<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
587        where
588            D: ::serde::Deserializer<'de>,
589        {
590            let value = Cow::<str>::deserialize(deserializer)?;
591            parse_decimal(&value).ok_or_else(|| invalid_decimal(&value))
592        }
593    }
594
595    /// Serde adapter for an optional canonical decimal string.
596    pub mod option_canonical_str {
597        use super::{Cow, Decimal, Deserialize, Serializer, invalid_decimal};
598        use crate::{parse_decimal, to_canonical_string};
599        use serde::Serialize;
600
601        /// Serializes an optional decimal as a canonical string or `null`.
602        ///
603        /// # Errors
604        /// Returns the serializer error when writing the option fails.
605        pub fn serialize<S>(value: &Option<Decimal>, serializer: S) -> Result<S::Ok, S::Error>
606        where
607            S: Serializer,
608        {
609            let canonical = value.as_ref().map(to_canonical_string);
610            canonical.serialize(serializer)
611        }
612
613        /// Deserializes an optional decimal from strings accepted by [`crate::parse_decimal`].
614        ///
615        /// # Errors
616        /// Returns the deserializer error when the input is not `null` or a
617        /// string, or when [`crate::parse_decimal`] rejects the string.
618        pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Decimal>, D::Error>
619        where
620            D: ::serde::Deserializer<'de>,
621        {
622            Option::<Cow<'de, str>>::deserialize(deserializer)?
623                .map(|value| parse_decimal(&value).ok_or_else(|| invalid_decimal(&value)))
624                .transpose()
625        }
626    }
627}
628
629/// Converts a decimal into a canonical string without scientific notation and
630/// without gratuitous trailing zeros.
631#[must_use]
632pub fn to_canonical_string(value: &Decimal) -> String {
633    let zero = zero();
634    if value == &zero {
635        return "0".to_owned();
636    }
637
638    let mut repr = backend::to_plain_string(value);
639    if let Some(dot) = repr.find('.') {
640        let mut end = repr.len();
641        while end > dot + 1 && repr.as_bytes()[end - 1] == b'0' {
642            end -= 1;
643        }
644        if end == dot + 1 {
645            end -= 1;
646        }
647        repr.truncate(end);
648    }
649    repr
650}
651
652#[cfg(test)]
653mod tests {
654    use std::str::FromStr;
655
656    use super::{
657        Decimal, RoundingStrategy, checked_div, parse_decimal, round_dp_with_strategy,
658        to_canonical_string, try_from_scaled_units, try_to_scaled_units,
659    };
660
661    #[test]
662    fn parse_rejects_scientific_notation() {
663        assert!(parse_decimal("1e3").is_none());
664        assert!(parse_decimal("2E-3").is_none());
665    }
666
667    #[test]
668    fn parse_accepts_standard_forms() {
669        assert_eq!(
670            parse_decimal("  +123.4500 ").unwrap(),
671            parse_decimal("123.45").unwrap()
672        );
673        assert_eq!(
674            parse_decimal("-42.1").unwrap(),
675            Decimal::from_str("-42.1").unwrap()
676        );
677    }
678
679    #[test]
680    fn parse_uses_backend_stable_plain_decimal_grammar() {
681        for (literal, canonical) in [
682            (".5", "0.5"),
683            ("1.", "1"),
684            ("+1", "1"),
685            ("-0.00", "0"),
686            ("001.2300", "1.23"),
687            (" \t\n+001.2300\r", "1.23"),
688        ] {
689            let parsed = parse_decimal(literal).unwrap_or_else(|| panic!("{literal} should parse"));
690            assert_eq!(to_canonical_string(&parsed), canonical);
691        }
692    }
693
694    #[test]
695    fn parse_rejects_non_plain_decimal_grammar() {
696        for literal in [
697            "", " ", "+", "-", ".", "+.", "-.", "+-1", "++1", "--1", "1_000", "1e3", "2E-3", "1 2",
698            "1.2.3",
699        ] {
700            assert!(parse_decimal(literal).is_none(), "{literal} should fail");
701        }
702    }
703
704    #[test]
705    fn parse_rejects_duplicate_explicit_signs() {
706        assert!(parse_decimal("+-1").is_none());
707        assert!(parse_decimal("++1").is_none());
708        assert!(parse_decimal("+").is_none());
709        assert!(parse_decimal("+1").is_some());
710        assert!(parse_decimal("-1").is_some());
711    }
712
713    #[test]
714    fn canonical_string_trims_trailing_zeros() {
715        let value = parse_decimal("123.4500").unwrap();
716        assert_eq!(to_canonical_string(&value), "123.45");
717        let integer = parse_decimal("1000").unwrap();
718        assert_eq!(to_canonical_string(&integer), "1000");
719    }
720
721    #[test]
722    fn canonical_string_normalizes_zero_sign() {
723        let negative_zero = parse_decimal("-0.00").unwrap();
724        assert_eq!(to_canonical_string(&negative_zero), "0");
725
726        let rounded_negative_zero = round_dp_with_strategy(
727            &parse_decimal("-0.0049").unwrap(),
728            2,
729            RoundingStrategy::ToZero,
730        );
731        assert_eq!(to_canonical_string(&rounded_negative_zero), "0");
732    }
733
734    #[test]
735    fn checked_div_returns_none_for_zero_divisor() {
736        let lhs = parse_decimal("10").unwrap();
737        let zero = parse_decimal("0.00").unwrap();
738        assert!(checked_div(&lhs, &zero).is_none());
739
740        let two = parse_decimal("2").unwrap();
741        let quotient = checked_div(&lhs, &two).unwrap();
742        assert_eq!(to_canonical_string(&quotient), "5");
743    }
744
745    #[test]
746    fn canonical_decimal_serde_uses_strings() {
747        #[derive(::serde::Serialize, ::serde::Deserialize, PartialEq, Debug)]
748        struct Payload {
749            #[serde(with = "crate::serde::canonical_str")]
750            value: Decimal,
751            #[serde(default, with = "crate::serde::option_canonical_str")]
752            optional: Option<Decimal>,
753        }
754
755        let payload = Payload {
756            value: parse_decimal("123.4500").unwrap(),
757            optional: Some(parse_decimal("0.5000").unwrap()),
758        };
759
760        let value = serde_json::to_value(&payload).unwrap();
761        assert_eq!(value["value"], serde_json::json!("123.45"));
762        assert_eq!(value["optional"], serde_json::json!("0.5"));
763        assert_eq!(serde_json::from_value::<Payload>(value).unwrap(), payload);
764
765        let missing_optional = serde_json::json!({ "value": "+1.2300" });
766        let parsed = serde_json::from_value::<Payload>(missing_optional).unwrap();
767        assert_eq!(to_canonical_string(&parsed.value), "1.23");
768        assert_eq!(parsed.optional, None);
769    }
770
771    #[test]
772    fn try_from_scaled_units_accepts_representable_values() {
773        let value = try_from_scaled_units(123_456, 3).unwrap();
774        assert_eq!(to_canonical_string(&value), "123.456");
775    }
776
777    #[test]
778    fn try_to_scaled_units_accepts_exact_values() {
779        let value = parse_decimal("123.4560").unwrap();
780        assert_eq!(try_to_scaled_units(&value, 6), Some(123_456_000));
781        assert_eq!(try_to_scaled_units(&value, 3), Some(123_456));
782
783        let negative = parse_decimal("-1.25").unwrap();
784        assert_eq!(try_to_scaled_units(&negative, 2), Some(-125));
785    }
786
787    #[test]
788    fn try_to_scaled_units_rejects_inexact_values_instead_of_rounding() {
789        let above_half = parse_decimal("1.250001").unwrap();
790        assert_eq!(try_to_scaled_units(&above_half, 1), None);
791
792        let tie = parse_decimal("1.25").unwrap();
793        assert_eq!(try_to_scaled_units(&tie, 1), None);
794    }
795
796    #[cfg(not(feature = "bigdecimal"))]
797    #[test]
798    fn try_from_scaled_units_rejects_rust_decimal_limits() {
799        assert!(try_from_scaled_units(i128::MAX, 0).is_none());
800        assert!(try_from_scaled_units(1, 29).is_none());
801    }
802}