nexus_decimal/decimal.rs
1//! Core `Decimal<B, D>` type definition and constructors.
2
3use crate::backing::Backing;
4
5/// Fixed-point decimal with compile-time backing type and precision.
6///
7/// `B` is the backing integer type (`i32`, `i64`, `i128`).
8/// `DECIMALS` is the number of fractional digits. Any combination
9/// where `10^DECIMALS` fits in `B` is valid — `Decimal<i64, 2>` for
10/// USD or `Decimal<i64, 8>` for BTC without any macro invocation.
11///
12/// The scale factor `10^DECIMALS` is validated at compile time.
13/// Invalid combinations (e.g., `Decimal<i32, 10>`) fail to compile
14/// when any associated constant or method is used.
15///
16/// # Examples
17///
18/// ```
19/// use nexus_decimal::Decimal;
20/// type D64 = Decimal<i64, 8>;
21///
22/// const PRICE: D64 = D64::new(100, 50_000_000); // 100.50
23/// const FEE: D64 = D64::from_raw(500_000); // 0.005
24/// const TOTAL: D64 = match PRICE.checked_add(FEE) {
25/// Some(v) => v,
26/// None => panic!("overflow"),
27/// };
28/// ```
29#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
30#[repr(transparent)]
31pub struct Decimal<B: Backing, const DECIMALS: u8> {
32 pub(crate) value: B,
33}
34
35/// Generates constructors and query methods for a concrete backing type.
36macro_rules! impl_decimal_core {
37 ($backing:ty, $pow10_fn:path, $max_exp:expr) => {
38 impl<const D: u8> Decimal<$backing, D> {
39 /// The scale factor `10^DECIMALS`.
40 ///
41 /// Validated at compile time: panics if `DECIMALS` is too
42 /// large for the backing type.
43 pub const SCALE: $backing = {
44 assert!(
45 (D as u32) <= $max_exp,
46 "DECIMALS too large for backing type"
47 );
48 $pow10_fn(D)
49 };
50
51 /// The number of fractional digits.
52 pub const DECIMALS: u8 = D;
53
54 /// Creates a `Decimal` from a raw pre-scaled value.
55 ///
56 /// No validation — the caller is responsible for ensuring
57 /// the value is in the expected scale.
58 #[inline(always)]
59 pub const fn from_raw(value: $backing) -> Self {
60 Self { value }
61 }
62
63 /// Returns the raw internal value (scaled by `10^DECIMALS`).
64 #[inline(always)]
65 pub const fn to_raw(self) -> $backing {
66 self.value
67 }
68
69 /// Creates a `Decimal` from integer and fractional parts.
70 ///
71 /// The fractional part is combined with the integer part as
72 /// `integer * SCALE + fractional`. For conventional usage,
73 /// pass a non-negative `fractional` less than `SCALE`.
74 /// For negative values, negate the integer part:
75 /// `new(-123, 45_000_000)` → `-123.45` (for `DECIMALS=8`).
76 ///
77 /// # Panics
78 ///
79 /// Panics if the result overflows the backing type.
80 ///
81 /// # Examples
82 ///
83 /// ```
84 /// use nexus_decimal::Decimal;
85 /// type D64 = Decimal<i64, 8>;
86 ///
87 /// const PRICE: D64 = D64::new(100, 50_000_000); // 100.50
88 /// const NEG: D64 = D64::new(-50, 25_000_000); // -50.25
89 /// ```
90 pub const fn new(integer: $backing, fractional: $backing) -> Self {
91 let Some(scaled) = integer.checked_mul(Self::SCALE) else {
92 panic!("overflow in Decimal::new: integer part too large")
93 };
94
95 let value = if integer >= 0 {
96 let Some(v) = scaled.checked_add(fractional) else {
97 panic!("overflow in Decimal::new")
98 };
99 v
100 } else {
101 let Some(v) = scaled.checked_sub(fractional) else {
102 panic!("overflow in Decimal::new")
103 };
104 v
105 };
106
107 Self { value }
108 }
109
110 /// Construct from integer part, fractional part, and sign.
111 ///
112 /// The fractional part is always positive (represents digits
113 /// after the decimal point). Use `negative` to control sign.
114 ///
115 /// This handles the `-0.5` case that `new()` cannot express
116 /// (because `-0 == 0` for integers).
117 ///
118 /// # Examples
119 ///
120 /// ```
121 /// use nexus_decimal::Decimal;
122 /// type D64 = Decimal<i64, 8>;
123 ///
124 /// let neg_half = D64::from_parts(0, 50_000_000, true);
125 /// assert_eq!(neg_half.unwrap().to_raw(), -50_000_000);
126 ///
127 /// let pos = D64::from_parts(1, 25_000_000, false);
128 /// assert_eq!(pos.unwrap().to_raw(), 125_000_000);
129 /// ```
130 pub const fn from_parts(
131 integer: $backing,
132 fractional: $backing,
133 negative: bool,
134 ) -> Option<Self> {
135 let Some(scaled) = integer.checked_mul(Self::SCALE) else {
136 return None;
137 };
138 let Some(abs) = scaled.checked_add(fractional) else {
139 return None;
140 };
141 if negative {
142 match abs.checked_neg() {
143 Some(v) => Some(Self { value: v }),
144 None => None,
145 }
146 } else {
147 Some(Self { value: abs })
148 }
149 }
150
151 /// Returns `true` if the value is zero.
152 #[inline(always)]
153 pub const fn is_zero(self) -> bool {
154 self.value == 0
155 }
156
157 /// Returns `true` if the value is strictly positive.
158 #[inline(always)]
159 pub const fn is_positive(self) -> bool {
160 self.value > 0
161 }
162
163 /// Returns `true` if the value is strictly negative.
164 #[inline(always)]
165 pub const fn is_negative(self) -> bool {
166 self.value < 0
167 }
168
169 /// Returns the signum: `-1`, `0`, or `1`.
170 #[inline(always)]
171 pub const fn signum(self) -> $backing {
172 self.value.signum()
173 }
174 }
175
176 impl<const D: u8> Default for Decimal<$backing, D> {
177 #[inline]
178 fn default() -> Self {
179 Self::ZERO
180 }
181 }
182 };
183}
184
185use crate::pow10::{pow10_i32, pow10_i64, pow10_i128};
186
187impl_decimal_core!(i32, pow10_i32, 9);
188impl_decimal_core!(i64, pow10_i64, 18);
189impl_decimal_core!(i128, pow10_i128, 38);