1use std::fmt;
2
3use crate::error::{Error, Result};
4
5const NANOMINA_PER_MINA: u64 = 1_000_000_000;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
19pub struct Currency(u64);
20
21impl Currency {
22 pub fn from_nanomina(nanomina: u64) -> Self {
24 Self(nanomina)
25 }
26
27 pub fn from_mina(s: &str) -> Result<Self> {
29 parse_decimal(s).map(Self)
30 }
31
32 pub fn from_graphql(s: &str) -> Result<Self> {
34 s.parse::<u64>()
35 .map(Self)
36 .map_err(|_| Error::InvalidCurrency(s.to_string()))
37 }
38
39 pub fn nanomina(&self) -> u64 {
41 self.0
42 }
43
44 pub fn mina(&self) -> String {
46 let whole = self.0 / NANOMINA_PER_MINA;
47 let frac = self.0 % NANOMINA_PER_MINA;
48 format!("{whole}.{frac:09}")
49 }
50
51 pub fn to_nanomina_str(&self) -> String {
53 self.0.to_string()
54 }
55
56 pub fn checked_add(self, rhs: Currency) -> Option<Currency> {
58 self.0.checked_add(rhs.0).map(Currency)
59 }
60
61 pub fn checked_sub(self, rhs: Currency) -> Result<Currency> {
63 self.0
64 .checked_sub(rhs.0)
65 .map(Currency)
66 .ok_or(Error::CurrencyUnderflow(self.0, rhs.0))
67 }
68
69 pub fn checked_mul(self, rhs: u64) -> Option<Currency> {
71 self.0.checked_mul(rhs).map(Currency)
72 }
73}
74
75impl fmt::Display for Currency {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 write!(f, "{}", self.mina())
78 }
79}
80
81impl std::ops::Add for Currency {
82 type Output = Currency;
83 fn add(self, rhs: Self) -> Self::Output {
84 Currency(self.0 + rhs.0)
85 }
86}
87
88impl std::ops::Sub for Currency {
89 type Output = Currency;
90 fn sub(self, rhs: Self) -> Self::Output {
92 Currency(self.0.checked_sub(rhs.0).expect("currency underflow"))
93 }
94}
95
96impl std::ops::Mul<u64> for Currency {
97 type Output = Currency;
98 fn mul(self, rhs: u64) -> Self::Output {
99 Currency(self.0 * rhs)
100 }
101}
102
103impl std::ops::Mul<Currency> for u64 {
104 type Output = Currency;
105 fn mul(self, rhs: Currency) -> Self::Output {
106 Currency(self * rhs.0)
107 }
108}
109
110fn parse_decimal(s: &str) -> Result<u64> {
112 let s = s.trim();
113 if s.is_empty() {
114 return Err(Error::InvalidCurrency(s.to_string()));
115 }
116
117 let (whole_str, frac_str) = match s.split_once('.') {
118 Some((w, f)) => (w, f),
119 None => (s, ""),
120 };
121
122 let whole: u64 = if whole_str.is_empty() {
123 0
124 } else {
125 whole_str
126 .parse()
127 .map_err(|_| Error::InvalidCurrency(s.to_string()))?
128 };
129
130 if frac_str.len() > 9 {
131 return Err(Error::InvalidCurrency(format!(
132 "too many decimal places (max 9): {s}"
133 )));
134 }
135
136 let frac: u64 = if frac_str.is_empty() {
137 0
138 } else {
139 let padded = format!("{frac_str:0<9}");
140 padded
141 .parse()
142 .map_err(|_| Error::InvalidCurrency(s.to_string()))?
143 };
144
145 whole
146 .checked_mul(NANOMINA_PER_MINA)
147 .and_then(|w| w.checked_add(frac))
148 .ok_or_else(|| Error::InvalidCurrency(format!("overflow: {s}")))
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn from_mina_integer() {
157 let c = Currency::from_mina("5").unwrap();
158 assert_eq!(c.nanomina(), 5_000_000_000);
159 }
160
161 #[test]
162 fn from_mina_decimal() {
163 let c = Currency::from_mina("1.5").unwrap();
164 assert_eq!(c.nanomina(), 1_500_000_000);
165 }
166
167 #[test]
168 fn from_mina_small() {
169 let c = Currency::from_mina("0.000000001").unwrap();
170 assert_eq!(c.nanomina(), 1);
171 }
172
173 #[test]
174 fn from_mina_no_whole() {
175 let c = Currency::from_mina(".5").unwrap();
176 assert_eq!(c.nanomina(), 500_000_000);
177 }
178
179 #[test]
180 fn from_graphql() {
181 let c = Currency::from_graphql("1500000000").unwrap();
182 assert_eq!(c.nanomina(), 1_500_000_000);
183 assert_eq!(c.mina(), "1.500000000");
184 }
185
186 #[test]
187 fn to_nanomina_str() {
188 let c = Currency::from_mina("3").unwrap();
189 assert_eq!(c.to_nanomina_str(), "3000000000");
190 }
191
192 #[test]
193 fn display() {
194 let c = Currency::from_nanomina(1);
195 assert_eq!(c.to_string(), "0.000000001");
196 assert_eq!(format!("{c}"), "0.000000001");
197 }
198
199 #[test]
200 fn addition() {
201 let a = Currency::from_mina("1").unwrap();
202 let b = Currency::from_mina("2").unwrap();
203 assert_eq!((a + b).nanomina(), 3_000_000_000);
204 }
205
206 #[test]
207 fn subtraction() {
208 let a = Currency::from_mina("3").unwrap();
209 let b = Currency::from_mina("1").unwrap();
210 assert_eq!((a - b).nanomina(), 2_000_000_000);
211 }
212
213 #[test]
214 fn checked_sub_underflow() {
215 let a = Currency::from_mina("1").unwrap();
216 let b = Currency::from_mina("2").unwrap();
217 assert!(a.checked_sub(b).is_err());
218 }
219
220 #[test]
221 fn multiplication() {
222 let c = Currency::from_mina("2").unwrap();
223 assert_eq!((c * 3).nanomina(), 6_000_000_000);
224 }
225
226 #[test]
227 fn reverse_multiplication() {
228 let c = Currency::from_mina("2").unwrap();
229 assert_eq!((3_u64 * c).nanomina(), 6_000_000_000);
230 }
231
232 #[test]
233 fn ordering() {
234 let a = Currency::from_mina("1").unwrap();
235 let b = Currency::from_mina("2").unwrap();
236 assert!(a < b);
237 assert!(b > a);
238 assert!(a <= a);
239 assert!(a >= a);
240 }
241
242 #[test]
243 fn hash_consistency() {
244 use std::collections::HashSet;
245 let a = Currency::from_mina("1").unwrap();
246 let b = Currency::from_nanomina(1_000_000_000);
247 let mut set = HashSet::new();
248 set.insert(a);
249 set.insert(b);
250 assert_eq!(set.len(), 1);
251 }
252
253 #[test]
254 fn from_mina_no_decimal() {
255 let c = Currency::from_mina("100").unwrap();
256 assert_eq!(c.nanomina(), 100_000_000_000);
257 }
258
259 #[test]
260 fn from_nanomina_explicit() {
261 let c = Currency::from_nanomina(500_000_000);
262 assert_eq!(c.mina(), "0.500000000");
263 assert_eq!(c.nanomina(), 500_000_000);
264 }
265
266 #[test]
267 fn small_nanomina_display() {
268 let c = Currency::from_nanomina(1);
269 assert_eq!(c.mina(), "0.000000001");
270 }
271
272 #[test]
273 fn zero_currency() {
274 let c = Currency::from_nanomina(0);
275 assert_eq!(c.mina(), "0.000000000");
276 assert_eq!(c.to_nanomina_str(), "0");
277 }
278
279 #[test]
280 fn checked_add_basic() {
281 let a = Currency::from_mina("1").unwrap();
282 let b = Currency::from_mina("2").unwrap();
283 assert_eq!(a.checked_add(b).unwrap().nanomina(), 3_000_000_000);
284 }
285
286 #[test]
287 fn checked_mul_basic() {
288 let c = Currency::from_mina("2").unwrap();
289 assert_eq!(c.checked_mul(3).unwrap().nanomina(), 6_000_000_000);
290 }
291
292 #[test]
293 fn equality_across_constructors() {
294 let a = Currency::from_mina("1").unwrap();
295 let b = Currency::from_nanomina(1_000_000_000);
296 let c = Currency::from_graphql("1000000000").unwrap();
297 assert_eq!(a, b);
298 assert_eq!(b, c);
299 }
300
301 #[test]
302 fn too_many_decimals() {
303 assert!(Currency::from_mina("1.0000000001").is_err());
304 }
305
306 #[test]
307 fn invalid_format() {
308 assert!(Currency::from_mina("abc").is_err());
309 assert!(Currency::from_mina("").is_err());
310 assert!(Currency::from_graphql("not_a_number").is_err());
311 }
312
313 #[test]
314 fn negative_input_rejected() {
315 assert!(Currency::from_mina("-1").is_err());
316 assert!(Currency::from_graphql("-500").is_err());
317 }
318}