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