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