Skip to main content

use_amount/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::error::Error;
6
7const MAX_SCALE: u8 = 18;
8
9/// Common scaled amount primitives.
10pub mod prelude {
11    pub use crate::{Amount, AmountError};
12}
13
14/// A decimal-safe amount represented as integer minor units and a decimal scale.
15#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub struct Amount {
17    minor_units: i128,
18    scale: u8,
19}
20
21impl Amount {
22    /// Creates an amount from integer minor units and a decimal scale.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`AmountError::ScaleTooLarge`] when `scale` is greater than 18.
27    pub const fn from_minor_units(minor_units: i128, scale: u8) -> Result<Self, AmountError> {
28        if scale > MAX_SCALE {
29            return Err(AmountError::ScaleTooLarge);
30        }
31
32        Ok(Self { minor_units, scale })
33    }
34
35    /// Creates a zero amount at the requested scale.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`AmountError::ScaleTooLarge`] when `scale` is greater than 18.
40    pub const fn zero(scale: u8) -> Result<Self, AmountError> {
41        Self::from_minor_units(0, scale)
42    }
43
44    /// Returns the integer minor-unit value.
45    #[must_use]
46    pub const fn minor_units(self) -> i128 {
47        self.minor_units
48    }
49
50    /// Returns the decimal scale.
51    #[must_use]
52    pub const fn scale(self) -> u8 {
53        self.scale
54    }
55
56    /// Returns whether this amount is zero.
57    #[must_use]
58    pub const fn is_zero(self) -> bool {
59        self.minor_units == 0
60    }
61
62    /// Returns whether this amount is greater than zero.
63    #[must_use]
64    pub const fn is_positive(self) -> bool {
65        self.minor_units > 0
66    }
67
68    /// Returns whether this amount is less than zero.
69    #[must_use]
70    pub const fn is_negative(self) -> bool {
71        self.minor_units < 0
72    }
73
74    /// Returns the absolute value of this amount.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`AmountError::Overflow`] when the absolute value cannot be represented.
79    pub const fn checked_abs(self) -> Result<Self, AmountError> {
80        match self.minor_units.checked_abs() {
81            Some(minor_units) => Self::from_minor_units(minor_units, self.scale),
82            None => Err(AmountError::Overflow),
83        }
84    }
85
86    /// Adds two same-scale amounts.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`AmountError::ScaleMismatch`] when scales differ and [`AmountError::Overflow`]
91    /// when the integer addition overflows.
92    pub fn checked_add(self, other: Self) -> Result<Self, AmountError> {
93        self.ensure_same_scale(other)?;
94        let minor_units = self
95            .minor_units
96            .checked_add(other.minor_units)
97            .ok_or(AmountError::Overflow)?;
98        Self::from_minor_units(minor_units, self.scale)
99    }
100
101    /// Subtracts two same-scale amounts.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`AmountError::ScaleMismatch`] when scales differ and [`AmountError::Overflow`]
106    /// when the integer subtraction overflows.
107    pub fn checked_sub(self, other: Self) -> Result<Self, AmountError> {
108        self.ensure_same_scale(other)?;
109        let minor_units = self
110            .minor_units
111            .checked_sub(other.minor_units)
112            .ok_or(AmountError::Overflow)?;
113        Self::from_minor_units(minor_units, self.scale)
114    }
115
116    /// Rescales an amount without losing precision.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`AmountError::ScaleTooLarge`] for unsupported scales,
121    /// [`AmountError::Overflow`] when scaling up overflows, and
122    /// [`AmountError::PrecisionLoss`] when scaling down would discard non-zero digits.
123    pub fn checked_rescale(self, new_scale: u8) -> Result<Self, AmountError> {
124        if new_scale > MAX_SCALE {
125            return Err(AmountError::ScaleTooLarge);
126        }
127
128        if new_scale == self.scale {
129            return Ok(self);
130        }
131
132        if new_scale > self.scale {
133            let multiplier = pow10(new_scale - self.scale)?;
134            let minor_units = self
135                .minor_units
136                .checked_mul(multiplier)
137                .ok_or(AmountError::Overflow)?;
138            return Self::from_minor_units(minor_units, new_scale);
139        }
140
141        let divisor = pow10(self.scale - new_scale)?;
142        if self.minor_units % divisor != 0 {
143            return Err(AmountError::PrecisionLoss);
144        }
145
146        Self::from_minor_units(self.minor_units / divisor, new_scale)
147    }
148
149    /// Removes trailing decimal zeroes from the minor-unit representation.
150    #[must_use]
151    pub const fn normalize(self) -> Self {
152        let mut minor_units = self.minor_units;
153        let mut scale = self.scale;
154
155        while scale > 0 && minor_units % 10 == 0 {
156            minor_units /= 10;
157            scale -= 1;
158        }
159
160        Self { minor_units, scale }
161    }
162
163    const fn ensure_same_scale(self, other: Self) -> Result<(), AmountError> {
164        if self.scale == other.scale {
165            Ok(())
166        } else {
167            Err(AmountError::ScaleMismatch {
168                left: self.scale,
169                right: other.scale,
170            })
171        }
172    }
173}
174
175impl fmt::Display for Amount {
176    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177        if self.scale == 0 {
178            return write!(formatter, "{}", self.minor_units);
179        }
180
181        let negative = self.minor_units < 0;
182        let absolute = self.minor_units.unsigned_abs();
183        let divisor = 10_u128.pow(u32::from(self.scale));
184        let whole = absolute / divisor;
185        let fraction = absolute % divisor;
186
187        if negative {
188            write!(formatter, "-")?;
189        }
190
191        write!(
192            formatter,
193            "{}.{:0width$}",
194            whole,
195            fraction,
196            width = usize::from(self.scale)
197        )
198    }
199}
200
201/// Errors returned by amount helpers.
202#[derive(Clone, Copy, Debug, Eq, PartialEq)]
203pub enum AmountError {
204    /// Decimal scales above 18 are intentionally unsupported.
205    ScaleTooLarge,
206    /// Arithmetic requires matching scales.
207    ScaleMismatch {
208        /// Left-hand amount scale.
209        left: u8,
210        /// Right-hand amount scale.
211        right: u8,
212    },
213    /// Integer arithmetic overflowed.
214    Overflow,
215    /// Rescaling would discard non-zero minor units.
216    PrecisionLoss,
217}
218
219impl fmt::Display for AmountError {
220    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
221        match self {
222            Self::ScaleTooLarge => formatter.write_str("amount scale cannot exceed 18"),
223            Self::ScaleMismatch { left, right } => write!(
224                formatter,
225                "amount scales must match, got {left} and {right}"
226            ),
227            Self::Overflow => formatter.write_str("amount arithmetic overflowed"),
228            Self::PrecisionLoss => formatter.write_str("amount rescale would lose precision"),
229        }
230    }
231}
232
233impl Error for AmountError {}
234
235fn pow10(exponent: u8) -> Result<i128, AmountError> {
236    10_i128
237        .checked_pow(u32::from(exponent))
238        .ok_or(AmountError::Overflow)
239}
240
241#[cfg(test)]
242mod tests {
243    use super::{Amount, AmountError};
244
245    #[test]
246    fn formats_scaled_amounts() -> Result<(), AmountError> {
247        assert_eq!(Amount::from_minor_units(12_345, 2)?.to_string(), "123.45");
248        assert_eq!(Amount::from_minor_units(-5, 2)?.to_string(), "-0.05");
249        assert_eq!(Amount::from_minor_units(42, 0)?.to_string(), "42");
250        Ok(())
251    }
252
253    #[test]
254    fn adds_and_subtracts_same_scale_amounts() -> Result<(), AmountError> {
255        let left = Amount::from_minor_units(10_000, 2)?;
256        let right = Amount::from_minor_units(2_500, 2)?;
257
258        assert_eq!(left.checked_add(right)?.minor_units(), 12_500);
259        assert_eq!(left.checked_sub(right)?.minor_units(), 7_500);
260        Ok(())
261    }
262
263    #[test]
264    fn rejects_mismatched_scales() -> Result<(), AmountError> {
265        let left = Amount::from_minor_units(100, 2)?;
266        let right = Amount::from_minor_units(100, 3)?;
267
268        assert_eq!(
269            left.checked_add(right),
270            Err(AmountError::ScaleMismatch { left: 2, right: 3 })
271        );
272        Ok(())
273    }
274
275    #[test]
276    fn rescales_without_precision_loss() -> Result<(), AmountError> {
277        let amount = Amount::from_minor_units(123, 2)?;
278        assert_eq!(amount.checked_rescale(4)?.minor_units(), 12_300);
279        assert_eq!(
280            Amount::from_minor_units(12_300, 4)?.checked_rescale(2)?,
281            amount
282        );
283        assert_eq!(amount.checked_rescale(1), Err(AmountError::PrecisionLoss));
284        Ok(())
285    }
286
287    #[test]
288    fn normalizes_trailing_zeroes() -> Result<(), AmountError> {
289        assert_eq!(
290            Amount::from_minor_units(12_300, 4)?.normalize(),
291            Amount::from_minor_units(123, 2)?
292        );
293        assert_eq!(
294            Amount::from_minor_units(0, 4)?.normalize(),
295            Amount::from_minor_units(0, 0)?
296        );
297        Ok(())
298    }
299}