Skip to main content

icydb_schema/decimal/
mod.rs

1//! Module: types::decimal
2//! Defines the fixed-point decimal runtime type and its arithmetic,
3//! normalization, and value-conversion helpers.
4
5mod arithmetic;
6mod compare;
7mod text;
8mod wire;
9
10#[cfg(test)]
11mod tests;
12
13use crate::NumericValue;
14use std::fmt::{Display, Formatter};
15use std::str::FromStr;
16
17// We cap scale at 28 to keep i128 intermediate math practical while still
18// covering common fixed-point workloads (including e8/e18 compatibility).
19pub(crate) const MAX_SUPPORTED_SCALE: u32 = 28;
20pub(crate) const DEFAULT_DIVISION_SCALE: u32 = 18;
21pub(crate) const DECIMAL_DIGIT_BUFFER_LEN: usize = 39;
22
23///
24/// DecimalParts
25///
26/// Canonical decomposition of a `Decimal`.
27///
28/// `mantissa * 10^-scale` reconstructs the represented value, and the mantissa
29/// carries the sign.
30///
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct DecimalParts {
34    mantissa: i128,
35    scale: u32,
36}
37
38impl DecimalParts {
39    /// Return the canonical decimal mantissa component.
40    #[must_use]
41    pub const fn mantissa(&self) -> i128 {
42        self.mantissa
43    }
44
45    /// Return the canonical decimal scale component.
46    #[must_use]
47    pub const fn scale(&self) -> u32 {
48        self.scale
49    }
50}
51
52///
53/// ParseDecimalError
54///
55/// User-facing parse failure for decimal text input.
56///
57/// This keeps text parsing errors explicit without pulling transport or
58/// arithmetic semantics into the error surface.
59///
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub struct ParseDecimalError {
63    reason: ParseDecimalErrorReason,
64}
65
66impl ParseDecimalError {
67    pub(crate) const fn new(reason: ParseDecimalErrorReason) -> Self {
68        Self { reason }
69    }
70
71    /// Return the compact reason code for this parse failure.
72    #[must_use]
73    pub const fn reason(&self) -> ParseDecimalErrorReason {
74        self.reason
75    }
76}
77
78impl std::error::Error for ParseDecimalError {}
79
80impl Display for ParseDecimalError {
81    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
82        f.write_str("decimal parse error")
83    }
84}
85
86///
87/// ParseDecimalErrorReason
88///
89/// Compact decimal parse-failure reason.
90///
91
92#[derive(Clone, Copy, Debug, Eq, PartialEq)]
93#[repr(u8)]
94pub enum ParseDecimalErrorReason {
95    Empty,
96    ExponentNotationUnsupported,
97    FractionalLengthOverflow,
98    ScaleOverflow,
99    MantissaOverflow,
100    ScaleExceedsSupportedRange,
101    InvalidSignificand,
102    InvalidDigits,
103}
104
105///
106/// Decimal
107///
108/// Owned fixed-point decimal with an explicit i128 mantissa and base-10 scale.
109/// Candid and Serde encode and decode decimal text in every format.
110///
111/// Arithmetic saturates on overflow, division by zero resolves to `ZERO`, and
112/// normalization keeps equivalent values on one canonical representation.
113///
114
115#[derive(Clone, Copy, Debug, Default)]
116pub struct Decimal {
117    mantissa: i128,
118    scale: u32,
119}
120
121impl Decimal {
122    pub const ZERO: Self = Self {
123        mantissa: 0,
124        scale: 0,
125    };
126
127    /// Returns the maximum supported decimal scale.
128    #[must_use]
129    pub const fn max_supported_scale() -> u32 {
130        MAX_SUPPORTED_SCALE
131    }
132
133    /// Construct a decimal from mantissa and scale.
134    ///
135    /// # Panics
136    ///
137    /// Panics when `scale` exceeds the supported range.
138    #[must_use]
139    pub const fn new(num: i64, scale: u32) -> Self {
140        assert!(
141            scale <= MAX_SUPPORTED_SCALE,
142            "decimal scale exceeds supported range"
143        );
144        Self::new_unchecked(num, scale)
145    }
146
147    /// Fallible constructor from mantissa and scale.
148    #[must_use]
149    pub const fn try_new(num: i64, scale: u32) -> Option<Self> {
150        if scale > MAX_SUPPORTED_SCALE {
151            return None;
152        }
153
154        Some(Self::new_unchecked(num, scale))
155    }
156
157    /// Unchecked constructor from mantissa and scale.
158    ///
159    /// This constructor may violate the decimal scale invariant and should only
160    /// be used when the caller already enforces `scale <= MAX_SUPPORTED_SCALE`.
161    ///
162    /// # Safety
163    ///
164    /// This bypasses all `Decimal` invariants, including scale bounds. It must
165    /// only be used by checked constructors, tests, or controlled internal
166    /// construction. Never call it from runtime execution paths.
167    #[must_use]
168    pub(crate) const fn new_unchecked(num: i64, scale: u32) -> Self {
169        Self {
170            mantissa: num as i128,
171            scale,
172        }
173    }
174
175    /// Fallible conversion from common numeric types.
176    ///
177    /// This path is lossy for float inputs and may lose precision for large values.
178    /// Prefer exact integer constructors (`from_i64`, `from_u64`) or explicit
179    /// float constructors (`from_f32_lossy`, `from_f64_lossy`) when possible.
180    pub fn from_num<N: NumericValue>(n: N) -> Option<Self> {
181        n.try_to_decimal()
182    }
183
184    /// Exact conversion from `i64`.
185    #[must_use]
186    pub const fn from_i64(n: i64) -> Option<Self> {
187        Some(Self {
188            mantissa: n as i128,
189            scale: 0,
190        })
191    }
192
193    /// Exact conversion from `u64`.
194    #[must_use]
195    pub const fn from_u64(n: u64) -> Option<Self> {
196        Some(Self {
197            mantissa: n as i128,
198            scale: 0,
199        })
200    }
201
202    /// Exact conversion from `i128`.
203    #[must_use]
204    pub const fn from_i128(n: i128) -> Option<Self> {
205        Some(Self {
206            mantissa: n,
207            scale: 0,
208        })
209    }
210
211    /// Exact conversion from `u128`.
212    #[must_use]
213    pub fn from_u128(n: u128) -> Option<Self> {
214        Some(Self {
215            mantissa: i128::try_from(n).ok()?,
216            scale: 0,
217        })
218    }
219
220    /// Explicit lossy conversion from `f32`.
221    ///
222    /// Uses decimal text round-tripping from the binary float representation.
223    /// This is intentionally lossy and should be used only when float input is required.
224    #[must_use]
225    pub fn from_f32_lossy(n: f32) -> Option<Self> {
226        if !n.is_finite() {
227            return None;
228        }
229
230        Self::from_str(&n.to_string()).ok()
231    }
232
233    /// Explicit lossy conversion from `f64`.
234    ///
235    /// Uses decimal text round-tripping from the binary float representation.
236    /// This is intentionally lossy and should be used only when float input is required.
237    #[must_use]
238    pub fn from_f64_lossy(n: f64) -> Option<Self> {
239        if !n.is_finite() {
240            return None;
241        }
242
243        Self::from_str(&n.to_string()).ok()
244    }
245
246    ///
247    /// PARTS
248    ///
249
250    /// Decompose into mantissa and scale.
251    #[must_use]
252    pub const fn parts(&self) -> DecimalParts {
253        DecimalParts {
254            mantissa: self.mantissa,
255            scale: self.scale,
256        }
257    }
258
259    /// Returns true if the decimal has no fractional component.
260    #[must_use]
261    pub const fn is_integer(&self) -> bool {
262        self.scale == 0
263    }
264
265    /// Scale by 10^target_scale and require an integer result.
266    ///
267    /// Returns `None` if:
268    /// - fractional precision would be lost
269    /// - integer overflow occurs
270    #[must_use]
271    pub fn scale_to_integer(&self, target_scale: u32) -> Option<i128> {
272        if self.scale > target_scale {
273            return None;
274        }
275
276        let factor = Self::checked_pow10(target_scale - self.scale)?;
277        self.mantissa.checked_mul(factor)
278    }
279
280    /// Convert to `i32` when the decimal is integral and in range.
281    #[must_use]
282    pub fn to_i32(&self) -> Option<i32> {
283        self.to_i64().and_then(|value| i32::try_from(value).ok())
284    }
285
286    /// Convert to `i64` when the decimal is integral and in range.
287    #[must_use]
288    pub fn to_i64(&self) -> Option<i64> {
289        let integer = Self::decimal_integer_value(self.mantissa, self.scale)?;
290
291        i64::try_from(integer).ok()
292    }
293
294    /// Convert to `i128` when the decimal is integral.
295    #[must_use]
296    pub fn to_i128(&self) -> Option<i128> {
297        Self::decimal_integer_value(self.mantissa, self.scale)
298    }
299
300    /// Convert to `u64` when the decimal is integral and in range.
301    #[must_use]
302    pub fn to_u64(&self) -> Option<u64> {
303        let integer = Self::decimal_integer_value(self.mantissa, self.scale)?;
304
305        u64::try_from(integer).ok()
306    }
307
308    /// Convert to `u128` when the decimal is integral and in range.
309    #[must_use]
310    pub fn to_u128(&self) -> Option<u128> {
311        let integer = Self::decimal_integer_value(self.mantissa, self.scale)?;
312
313        u128::try_from(integer).ok()
314    }
315
316    /// Convert to `f32` when the decimal is finite in `f32`.
317    #[must_use]
318    #[expect(clippy::cast_possible_truncation)]
319    pub fn to_f32(&self) -> Option<f32> {
320        self.to_f64().and_then(|value| {
321            let float = value as f32;
322            if float.is_finite() { Some(float) } else { None }
323        })
324    }
325
326    /// Convert to `f64` when the decimal is finite in `f64`.
327    #[must_use]
328    #[expect(clippy::cast_precision_loss)]
329    pub fn to_f64(&self) -> Option<f64> {
330        let divisor = 10f64.powi(i32::try_from(self.scale).ok()?);
331        let value = (self.mantissa as f64) / divisor;
332
333        if value.is_finite() { Some(value) } else { None }
334    }
335
336    /// Fallibly build from a raw mantissa and scale.
337    #[must_use]
338    pub const fn try_from_i128_with_scale(num: i128, scale: u32) -> Option<Self> {
339        Self::checked_from_mantissa_scale(num, scale)
340    }
341
342    /// Build from a raw mantissa and scale.
343    ///
344    /// # Panics
345    ///
346    /// Panics when the mantissa and scale cannot be represented without
347    /// violating the decimal scale invariant.
348    #[must_use]
349    pub const fn from_i128_with_scale(num: i128, scale: u32) -> Self {
350        Self::try_from_i128_with_scale(num, scale).expect("decimal invariant")
351    }
352
353    /// Normalize trailing zeros.
354    #[must_use]
355    pub const fn normalize(&self) -> Self {
356        let (mantissa, scale) = self.normalized_parts();
357        Self { mantissa, scale }
358    }
359
360    /// Returns `true` if the value is negative.
361    #[must_use]
362    pub const fn is_sign_negative(&self) -> bool {
363        self.mantissa < 0
364    }
365
366    /// Returns the number of fractional decimal places.
367    #[must_use]
368    pub const fn scale(&self) -> u32 {
369        self.scale
370    }
371
372    /// Returns the mantissa component.
373    #[must_use]
374    pub const fn mantissa(&self) -> i128 {
375        self.mantissa
376    }
377
378    /// Returns `true` if the value is zero.
379    #[must_use]
380    pub const fn is_zero(&self) -> bool {
381        self.mantissa == 0
382    }
383
384    const fn normalized_parts(&self) -> (i128, u32) {
385        Self::normalize_parts(self.mantissa, self.scale)
386    }
387
388    const fn checked_from_mantissa_scale(mantissa: i128, scale: u32) -> Option<Self> {
389        if scale <= MAX_SUPPORTED_SCALE {
390            return Some(Self { mantissa, scale });
391        }
392
393        let mut m = mantissa;
394        let mut s = scale;
395
396        while s > MAX_SUPPORTED_SCALE {
397            if m == 0 {
398                return Some(Self {
399                    mantissa: 0,
400                    scale: MAX_SUPPORTED_SCALE,
401                });
402            }
403
404            if m % 10 != 0 {
405                return None;
406            }
407
408            m /= 10;
409            s -= 1;
410        }
411
412        Some(Self {
413            mantissa: m,
414            scale: s,
415        })
416    }
417
418    const fn checked_pow10(power: u32) -> Option<i128> {
419        10i128.checked_pow(power)
420    }
421
422    fn decimal_integer_value(mantissa: i128, scale: u32) -> Option<i128> {
423        if scale == 0 {
424            return Some(mantissa);
425        }
426
427        let divisor = Self::checked_pow10(scale)?;
428        if mantissa % divisor != 0 {
429            return None;
430        }
431
432        Some(mantissa / divisor)
433    }
434
435    const fn normalize_parts(mantissa: i128, scale: u32) -> (i128, u32) {
436        if mantissa == 0 {
437            return (0, 0);
438        }
439
440        let mut m = mantissa;
441        let mut s = scale;
442
443        while s > 0 {
444            if m % 10 != 0 {
445                break;
446            }
447
448            m /= 10;
449            s -= 1;
450        }
451
452        (m, s)
453    }
454
455    const fn saturating_extreme(scale: u32, negative: bool) -> Self {
456        let mantissa = if negative { i128::MIN } else { i128::MAX };
457        Self { mantissa, scale }
458    }
459}
460
461impl NumericValue for Decimal {
462    fn try_to_decimal(&self) -> Option<Self> {
463        Some(*self)
464    }
465
466    fn try_from_decimal(value: Decimal) -> Option<Self> {
467        Some(value)
468    }
469}