decimal_scaled/support/display.rs
1//! [`core::fmt`] formatters and [`core::str::FromStr`] for [`D38`].
2//! The same surface is emitted for every width via
3//! [`crate::macros::display::decl_decimal_display!`] and
4//! [`crate::macros::from_str::decl_decimal_from_str!`]; this file
5//! contains the hand-written D38 implementation and serves as the
6//! shape reference for the macro emissions.
7//!
8//! # Parser factoring
9//!
10//! [`parse_components`] is the shared string-parsing front-end (sign /
11//! dot / digit-character validation, plus the overlong-fractional and
12//! leading-zero checks). The arithmetic accumulator that turns the
13//! integer / fractional digit slices into a storage value is
14//! *per-storage*:
15//!
16//! - Narrow tier (D9 / D18 / D38) accumulates in `u128` inside
17//! [`parse_decimal_bits`] — fast and the `10^SCALE` multiplier always
18//! fits since SCALE ≤ 38.
19//! - Wide tier (D76 … D1231) accumulates in the storage type itself
20//! via the per-width body emitted by
21//! [`crate::macros::from_str::decl_decimal_from_str!`]'s `wide` arm.
22//! The integer arithmetic happens at the storage width so the
23//! `10^SCALE` multiplier never overflows even at SCALE = 1230.
24//!
25//! # Display format
26//!
27//! [`fmt::Display`] formats as a base-10 decimal literal: integer digits,
28//! a `.`, then exactly `SCALE` fractional digits (trailing zeros are always
29//! emitted). At `SCALE = 12`, `1.5` displays as `1.500000000000`. The output
30//! is bit-faithful: parsing it back through [`core::str::FromStr`] returns
31//! the identical storage value.
32//!
33//! # Debug format
34//!
35//! [`fmt::Debug`] wraps the [`fmt::Display`] output with a scale annotation:
36//! `D38<SCALE>(...)`. This replaces the default derived format, which would
37//! show only the raw `i128` storage.
38//!
39//! # Scientific notation
40//!
41//! [`fmt::LowerExp`] and [`fmt::UpperExp`] emit scientific notation (`1.5e0`
42//! / `1.5E0`). Trailing zeros in the mantissa are stripped.
43//!
44//! # Storage-level radix formats
45//!
46//! [`fmt::LowerHex`], [`fmt::UpperHex`], [`fmt::Octal`], and [`fmt::Binary`]
47//! format the **raw `i128` storage** (= `value * 10^SCALE`), not the decimal
48//! value. For example, `D38s12::ONE` (storage `10^12`) prints in lower-hex
49//! as `e8d4a51000`.
50//!
51//! # `FromStr`
52//!
53//! Parses canonical decimal literals. Accepted forms:
54//! - Integer-only: `42` parses as `42 * 10^SCALE`.
55//! - Decimal with up to `SCALE` fractional digits: `1.5`, `1.500`.
56//! - Optional sign prefix: `-` or `+`.
57//! - Bare zero: `0` or `0.0`.
58//!
59//! Rejected forms (with the corresponding [`ParseError`] variant):
60//! - Empty string: [`ParseError::Empty`].
61//! - Sign with no digits: [`ParseError::SignOnly`].
62//! - Redundant leading zeros (`01`, `00`): [`ParseError::LeadingZero`].
63//! - More than `SCALE` fractional digits: [`ParseError::OverlongFractional`].
64//! - Scientific notation (`1e3`): [`ParseError::ScientificNotation`].
65//! - Missing digits on either side of the point (`.5`, `5.`):
66//! [`ParseError::MissingDigits`].
67//! - Non-digit, non-sign, non-dot characters: [`ParseError::InvalidChar`].
68//! - Magnitudes outside `[D38::MIN, D38::MAX]`: [`ParseError::OutOfRange`].
69
70use core::fmt;
71
72use crate::types::widths::{ParseError, D38};
73
74#[cfg(feature = "alloc")]
75extern crate alloc;
76
77// ──────────────────────────────────────────────────────────────────────
78// Display and Debug are emitted by the `decl_decimal_display!` macro
79// invoked from `types/widths.rs`; the macro itself lives in
80// `src/macros/display.rs` and handles all widths uniformly.
81// ──────────────────────────────────────────────────────────────────────
82
83// ──────────────────────────────────────────────────────────────────────
84// LowerExp / UpperExp -- scientific notation
85// ──────────────────────────────────────────────────────────────────────
86
87impl<const SCALE: u32> fmt::LowerExp for D38<SCALE> {
88 /// Formats the value in scientific notation with a lowercase `e`.
89 ///
90 /// Trailing zeros in the mantissa are stripped, so `1.500000000000`
91 /// formats as `1.5e0`. Zero formats as `0e0`.
92 ///
93 /// # Precision
94 ///
95 /// Strict: all arithmetic is integer-only; result is bit-exact.
96 ///
97 /// # Examples
98 ///
99 /// ```
100 /// use decimal_scaled::D38s12;
101 ///
102 /// let v = D38s12::from_bits(1_500_000_000_000);
103 /// assert_eq!(format!("{v:e}"), "1.5e0");
104 ///
105 /// let sub = D38s12::from_bits(1_500_000_000);
106 /// assert_eq!(format!("{sub:e}"), "1.5e-3");
107 /// ```
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 format_exp(self.0, SCALE, false, f)
110 }
111}
112
113impl<const SCALE: u32> fmt::UpperExp for D38<SCALE> {
114 /// Formats the value in scientific notation with an uppercase `E`.
115 ///
116 /// Identical to [`fmt::LowerExp`] except the exponent separator is `E`.
117 ///
118 /// # Precision
119 ///
120 /// Strict: all arithmetic is integer-only; result is bit-exact.
121 ///
122 /// # Examples
123 ///
124 /// ```
125 /// use decimal_scaled::D38s12;
126 ///
127 /// let v = D38s12::from_bits(1_500_000_000_000);
128 /// assert_eq!(format!("{v:E}"), "1.5E0");
129 /// ```
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 format_exp(self.0, SCALE, true, f)
132 }
133}
134
135/// Shared implementation for `LowerExp` and `UpperExp`.
136///
137/// Builds the decimal digit string in a fixed 40-byte stack buffer
138/// (a `u128` has at most 39 digits) so no heap allocation is needed.
139///
140/// # Precision
141///
142/// Strict: all arithmetic is integer-only; result is bit-exact.
143fn format_exp(raw: i128, scale: u32, upper: bool, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 let exp_char = if upper { 'E' } else { 'e' };
145 if raw == 0 {
146 return write!(f, "0{exp_char}0");
147 }
148 let negative = raw < 0;
149 let mag: u128 = raw.unsigned_abs();
150
151 // Collect decimal digits of `mag` LSB-first into the buffer,
152 // then reverse to get MSB-first order.
153 let mut buf = [0u8; 40];
154 let mut len = 0usize;
155 let mut n = mag;
156 while n > 0 {
157 let digit = (n % 10) as u8;
158 buf[len] = b'0' + digit;
159 len += 1;
160 n /= 10;
161 }
162 buf[..len].reverse();
163 let digits = &buf[..len];
164
165 // The decimal exponent for the leading digit is `(len - 1) - scale`.
166 let exp: i32 = (len as i32 - 1) - scale as i32;
167
168 // Strip trailing zeros from the mantissa digit string.
169 let mut frac_end = len;
170 while frac_end > 1 && digits[frac_end - 1] == b'0' {
171 frac_end -= 1;
172 }
173 let mantissa_int = digits[0] as char;
174 let mantissa_frac = &digits[1..frac_end];
175
176 if negative {
177 f.write_str("-")?;
178 }
179 if mantissa_frac.is_empty() {
180 // Single-digit mantissa: emit without a decimal point.
181 write!(f, "{mantissa_int}{exp_char}{exp}")
182 } else {
183 f.write_fmt(format_args!("{mantissa_int}."))?;
184 // mantissa_frac contains only ASCII digit bytes; from_utf8 cannot fail.
185 let frac_str = core::str::from_utf8(mantissa_frac).map_err(|_| fmt::Error)?;
186 write!(f, "{frac_str}{exp_char}{exp}")
187 }
188}
189
190// ──────────────────────────────────────────────────────────────────────
191// `ParseError`'s `Display` and `Error` impls live in `src/error.rs`.
192
193
194/// Outcome of the string-parsing front-end: sign and the integer / fractional
195/// digit slices. Both byte slices contain only ASCII digits.
196///
197/// Centralises the sign / dot / digit-character state machine so the
198/// per-storage accumulators (`i128` for the narrow tier; the wide signed
199/// integers emitted via the from-str macro for the wide tier) only need
200/// to do the base-10 arithmetic.
201pub(crate) struct ParseComponents<'a> {
202 pub negative: bool,
203 pub int_str: &'a [u8],
204 pub frac_str: &'a [u8],
205}
206
207/// String-parsing front-end shared by every width.
208///
209/// Validates and splits the input into sign / integer-digits / fractional-
210/// digits. The `SCALE` parameter is needed only to reject overlong fractional
211/// parts — no arithmetic happens here, so wide-tier callers can drive their
212/// own storage-typed accumulator without overflow risk.
213///
214/// # Precision
215///
216/// Strict: integer-only string slicing; no arithmetic.
217pub(crate) fn parse_components<const SCALE: u32>(
218 s: &str,
219) -> Result<ParseComponents<'_>, ParseError> {
220 if s.is_empty() {
221 return Err(ParseError::Empty);
222 }
223
224 let bytes = s.as_bytes();
225 let mut idx = 0usize;
226
227 // Consume an optional leading sign byte.
228 let negative = match bytes[0] {
229 b'-' => {
230 idx += 1;
231 true
232 }
233 b'+' => {
234 idx += 1;
235 false
236 }
237 _ => false,
238 };
239 if idx == bytes.len() {
240 // Sign byte with nothing following it.
241 return Err(ParseError::SignOnly);
242 }
243
244 // Single forward pass: locate the decimal point; reject scientific
245 // notation and invalid characters immediately.
246 let mut dot_pos: Option<usize> = None;
247 {
248 let mut i = idx;
249 while i < bytes.len() {
250 let c = bytes[i];
251 match c {
252 b'0'..=b'9' => {}
253 b'.' => {
254 if dot_pos.is_some() {
255 // A second dot is an invalid character, not a
256 // missing-digit case.
257 return Err(ParseError::InvalidChar);
258 }
259 dot_pos = Some(i);
260 }
261 b'e' | b'E' => {
262 return Err(ParseError::ScientificNotation);
263 }
264 _ => return Err(ParseError::InvalidChar),
265 }
266 i += 1;
267 }
268 }
269
270 let (int_str, frac_str) = match dot_pos {
271 Some(p) => (&bytes[idx..p], &bytes[p + 1..]),
272 None => (&bytes[idx..], &[][..]),
273 };
274
275 if dot_pos.is_some() {
276 // Both sides of the dot must have at least one digit.
277 if int_str.is_empty() || frac_str.is_empty() {
278 return Err(ParseError::MissingDigits);
279 }
280 } else if int_str.is_empty() {
281 return Err(ParseError::SignOnly);
282 }
283
284 // Allow `0` and `0.x` but reject `00`, `01`, `01.5`.
285 if int_str.len() > 1 && int_str[0] == b'0' {
286 return Err(ParseError::LeadingZero);
287 }
288
289 // More than SCALE fractional digits would lose precision on round-trip.
290 if frac_str.len() > SCALE as usize {
291 return Err(ParseError::OverlongFractional);
292 }
293
294 Ok(ParseComponents {
295 negative,
296 int_str,
297 frac_str,
298 })
299}
300
301/// Core decimal string parser for `D38`-class native-`i128` storage.
302///
303/// Drives [`parse_components`] and accumulates the storage value in `u128`
304/// (which avoids the `i128::MIN` asymmetry), then applies the sign.
305///
306/// The wide tier (D76 … D1231) uses [`crate::macros::from_str`] to emit a
307/// per-storage accumulator with the same shape; the front-end is shared but
308/// the arithmetic happens at the storage width so `10^SCALE` cannot
309/// overflow.
310///
311/// # Precision
312///
313/// Strict: all arithmetic is integer-only; result is bit-exact.
314pub(crate) fn parse_decimal_bits<const SCALE: u32>(s: &str) -> Result<i128, ParseError> {
315 parse_decimal::<SCALE>(s).map(crate::types::widths::D38::to_bits)
316}
317
318fn parse_decimal<const SCALE: u32>(s: &str) -> Result<D38<SCALE>, ParseError> {
319 let ParseComponents {
320 negative,
321 int_str,
322 frac_str,
323 } = parse_components::<SCALE>(s)?;
324
325 // Accumulate the storage value as u128 (avoids the i128::MIN asymmetry)
326 // and apply the sign at the very end.
327 let multiplier: u128 = 10u128.pow(SCALE);
328
329 // Parse the integer part and scale it by 10^SCALE.
330 let mut int_value: u128 = 0;
331 for &b in int_str {
332 let digit = u128::from(b - b'0');
333 int_value = match int_value.checked_mul(10).and_then(|v| v.checked_add(digit)) {
334 Some(v) => v,
335 None => return Err(ParseError::OutOfRange),
336 };
337 }
338 let int_scaled = match int_value.checked_mul(multiplier) {
339 Some(v) => v,
340 None => return Err(ParseError::OutOfRange),
341 };
342
343 // Parse the fractional part, then pad to exactly SCALE digits by
344 // multiplying by 10^(SCALE - frac_len).
345 let mut frac_value: u128 = 0;
346 let frac_len = frac_str.len();
347 for &b in frac_str {
348 let digit = u128::from(b - b'0');
349 frac_value = match frac_value
350 .checked_mul(10)
351 .and_then(|v| v.checked_add(digit))
352 {
353 Some(v) => v,
354 None => return Err(ParseError::OutOfRange),
355 };
356 }
357 let pad = (SCALE as usize) - frac_len;
358 if pad > 0 {
359 let pad_factor: u128 = 10u128.pow(pad as u32);
360 frac_value = match frac_value.checked_mul(pad_factor) {
361 Some(v) => v,
362 None => return Err(ParseError::OutOfRange),
363 };
364 }
365
366 let combined = match int_scaled.checked_add(frac_value) {
367 Some(v) => v,
368 None => return Err(ParseError::OutOfRange),
369 };
370
371 // Convert to i128. The negative branch handles i128::MIN whose absolute
372 // value (i128::MAX + 1) is not representable as a positive i128.
373 let raw: i128 = if negative {
374 let neg_min_abs: u128 = (i128::MAX as u128) + 1;
375 if combined > neg_min_abs {
376 return Err(ParseError::OutOfRange);
377 }
378 if combined == neg_min_abs {
379 i128::MIN
380 } else {
381 -(combined as i128)
382 }
383 } else {
384 if combined > i128::MAX as u128 {
385 return Err(ParseError::OutOfRange);
386 }
387 combined as i128
388 };
389
390 Ok(D38::<SCALE>::from_bits(raw))
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use crate::types::widths::{D38s12, D38};
397 #[cfg(feature = "alloc")]
398 use alloc::format;
399 #[cfg(feature = "alloc")]
400 use alloc::string::ToString;
401
402 // ── Display ──
403
404 /// ZERO renders as `0.000000000000` at SCALE = 12.
405 #[cfg(feature = "alloc")]
406 #[test]
407 fn display_zero_renders() {
408 assert_eq!(D38s12::ZERO.to_string(), "0.000000000000");
409 }
410
411 /// ONE renders as `1.000000000000` at SCALE = 12.
412 #[cfg(feature = "alloc")]
413 #[test]
414 fn display_one_renders() {
415 assert_eq!(D38s12::ONE.to_string(), "1.000000000000");
416 }
417
418 /// `1.5` renders with full SCALE fractional digits.
419 #[cfg(feature = "alloc")]
420 #[test]
421 fn display_one_point_five_renders() {
422 let v = D38s12::from_bits(1_500_000_000_000);
423 assert_eq!(v.to_string(), "1.500000000000");
424 }
425
426 /// Negative values get a leading `-`.
427 #[cfg(feature = "alloc")]
428 #[test]
429 fn display_negative_renders() {
430 let v = D38s12::from_bits(-1_500_000_000_000);
431 assert_eq!(v.to_string(), "-1.500000000000");
432 }
433
434 /// `0.001` (sub-unit positive) keeps leading-zero fractional.
435 #[cfg(feature = "alloc")]
436 #[test]
437 fn display_subunit_keeps_leading_zeros() {
438 // 0.001 = 1_000_000_000 at SCALE 12
439 let v = D38s12::from_bits(1_000_000_000);
440 assert_eq!(v.to_string(), "0.001000000000");
441 }
442
443 /// MAX renders without panicking. Spot-check the canonical form
444 /// at SCALE 12: `170141183460469231731687303.715884105727`.
445 #[cfg(feature = "alloc")]
446 #[test]
447 fn display_max_does_not_panic() {
448 let s = D38s12::MAX.to_string();
449 assert_eq!(s, "170141183460469231731687303.715884105727");
450 }
451
452 /// MIN renders without panicking. The unsigned-abs path handles
453 /// the i128::MIN special case (|MIN| = MAX + 1, so the trailing
454 /// digit is 8 not 7).
455 #[cfg(feature = "alloc")]
456 #[test]
457 fn display_min_does_not_panic() {
458 let s = D38s12::MIN.to_string();
459 assert_eq!(s, "-170141183460469231731687303.715884105728");
460 }
461
462 /// SCALE = 0 has no decimal point.
463 #[cfg(feature = "alloc")]
464 #[test]
465 fn display_scale_zero_no_dot() {
466 type D0 = D38<0>;
467 assert_eq!(D0::ONE.to_string(), "1");
468 assert_eq!(D0::ZERO.to_string(), "0");
469 assert_eq!(D0::from_bits(-42).to_string(), "-42");
470 }
471
472 // ── Debug ──
473
474 /// Debug delegates to Display + SCALE annotation.
475 #[cfg(feature = "alloc")]
476 #[test]
477 fn debug_includes_scale_and_value() {
478 let v = D38s12::from_bits(1_500_000_000_000);
479 let debug_str = format!("{v:?}");
480 assert_eq!(debug_str, "D38<12>(1.500000000000)");
481 }
482
483 /// Debug on ZERO at a non-12 scale.
484 #[cfg(feature = "alloc")]
485 #[test]
486 fn debug_other_scale() {
487 type D6 = D38<6>;
488 let v = D6::ZERO;
489 assert_eq!(format!("{v:?}"), "D38<6>(0.000000)");
490 }
491
492 // ── LowerExp / UpperExp ──
493
494 /// `1.0` -> `1e0` (single digit mantissa).
495 #[cfg(feature = "alloc")]
496 #[test]
497 fn lower_exp_one() {
498 let v = D38s12::ONE;
499 assert_eq!(format!("{v:e}"), "1e0");
500 }
501
502 /// `1.5` -> `1.5e0`.
503 #[cfg(feature = "alloc")]
504 #[test]
505 fn lower_exp_one_point_five() {
506 let v = D38s12::from_bits(1_500_000_000_000);
507 assert_eq!(format!("{v:e}"), "1.5e0");
508 }
509
510 /// `15.0` -> `1.5e1`.
511 #[cfg(feature = "alloc")]
512 #[test]
513 fn lower_exp_fifteen() {
514 let v = D38s12::from_bits(15_000_000_000_000);
515 assert_eq!(format!("{v:e}"), "1.5e1");
516 }
517
518 /// `0.0` -> `0e0`.
519 #[cfg(feature = "alloc")]
520 #[test]
521 fn lower_exp_zero() {
522 assert_eq!(format!("{:e}", D38s12::ZERO), "0e0");
523 }
524
525 /// Sub-unit value -> negative exponent. `0.0015 = 1.5e-3`.
526 #[cfg(feature = "alloc")]
527 #[test]
528 fn lower_exp_subunit_negative_exponent() {
529 // 0.0015 at SCALE 12 = 1_500_000_000
530 let v = D38s12::from_bits(1_500_000_000);
531 assert_eq!(format!("{v:e}"), "1.5e-3");
532 }
533
534 /// Negative value preserves sign.
535 #[cfg(feature = "alloc")]
536 #[test]
537 fn lower_exp_negative() {
538 let v = D38s12::from_bits(-1_500_000_000_000);
539 assert_eq!(format!("{v:e}"), "-1.5e0");
540 }
541
542 /// UpperExp uses `E`.
543 #[cfg(feature = "alloc")]
544 #[test]
545 fn upper_exp_uses_capital_e() {
546 let v = D38s12::from_bits(1_500_000_000_000);
547 assert_eq!(format!("{v:E}"), "1.5E0");
548 }
549
550 // ── LowerHex / UpperHex / Octal / Binary ──
551
552 /// LowerHex of D38s12::ONE is the hex of 10^12 (= 0xe8d4a51000),
553 /// NOT the hex of `1.0` formatted as a decimal in hex.
554 #[cfg(feature = "alloc")]
555 #[test]
556 fn lower_hex_is_storage() {
557 assert_eq!(format!("{:x}", D38s12::ONE), "e8d4a51000");
558 }
559
560 /// UpperHex of ONE: same digits in upper case.
561 #[cfg(feature = "alloc")]
562 #[test]
563 fn upper_hex_is_storage() {
564 assert_eq!(format!("{:X}", D38s12::ONE), "E8D4A51000");
565 }
566
567 /// Octal of ZERO is `0`.
568 #[cfg(feature = "alloc")]
569 #[test]
570 fn octal_zero() {
571 assert_eq!(format!("{:o}", D38s12::ZERO), "0");
572 }
573
574 /// Binary of ONE has the `10^12` bit pattern (40 bits).
575 #[cfg(feature = "alloc")]
576 #[test]
577 fn binary_one() {
578 // 10^12 in binary: 1110_1000_1101_0100_1010_0101_0001_0000_0000_0000
579 let s = format!("{:b}", D38s12::ONE);
580 assert_eq!(s, "1110100011010100101001010001000000000000");
581 }
582
583 // ── ParseError Display ──
584
585 #[cfg(feature = "alloc")]
586 #[test]
587 fn parse_error_display_messages() {
588 assert_eq!(ParseError::Empty.to_string(), "empty input");
589 assert_eq!(
590 ParseError::SignOnly.to_string(),
591 "sign with no digits"
592 );
593 assert_eq!(
594 ParseError::LeadingZero.to_string(),
595 "redundant leading zero in integer part"
596 );
597 assert_eq!(
598 ParseError::OverlongFractional.to_string(),
599 "fractional part exceeds SCALE digits"
600 );
601 assert_eq!(
602 ParseError::ScientificNotation.to_string(),
603 "scientific notation not accepted"
604 );
605 assert_eq!(
606 ParseError::InvalidChar.to_string(),
607 "invalid character"
608 );
609 assert_eq!(
610 ParseError::OutOfRange.to_string(),
611 "value out of representable range"
612 );
613 assert_eq!(
614 ParseError::MissingDigits.to_string(),
615 "decimal point with no adjacent digits"
616 );
617 }
618
619 // ── FromStr happy path ──
620
621 #[test]
622 fn from_str_zero() {
623 let v: D38s12 = "0".parse().unwrap();
624 assert_eq!(v, D38s12::ZERO);
625 let v: D38s12 = "0.0".parse().unwrap();
626 assert_eq!(v, D38s12::ZERO);
627 }
628
629 #[test]
630 fn from_str_one() {
631 let v: D38s12 = "1".parse().unwrap();
632 assert_eq!(v, D38s12::ONE);
633 let v: D38s12 = "1.0".parse().unwrap();
634 assert_eq!(v, D38s12::ONE);
635 }
636
637 /// Headline base-10 claim: `1.1` parses bit-exact.
638 #[test]
639 fn from_str_one_point_one_parses_exactly() {
640 let v: D38s12 = "1.1".parse().unwrap();
641 assert_eq!(v.to_bits(), 1_100_000_000_000);
642 }
643
644 /// Sign prefix.
645 #[test]
646 fn from_str_signs() {
647 let neg: D38s12 = "-1.5".parse().unwrap();
648 assert_eq!(neg.to_bits(), -1_500_000_000_000);
649
650 let pos: D38s12 = "+1.5".parse().unwrap();
651 assert_eq!(pos.to_bits(), 1_500_000_000_000);
652 }
653
654 /// Fractional with fewer digits than SCALE pads correctly.
655 #[test]
656 fn from_str_short_fractional_pads() {
657 // "0.5" at SCALE 12 -> 5_000_000_000 (= 0.5 * 10^12).
658 let v: D38s12 = "0.5".parse().unwrap();
659 assert_eq!(v.to_bits(), 500_000_000_000);
660 }
661
662 /// Fractional with exactly SCALE digits is the natural form.
663 #[test]
664 fn from_str_full_scale_fractional() {
665 let v: D38s12 = "1.500000000000".parse().unwrap();
666 assert_eq!(v.to_bits(), 1_500_000_000_000);
667 }
668
669 // ── FromStr error paths ──
670
671 #[test]
672 fn from_str_empty_is_err() {
673 let r: Result<D38s12, _> = "".parse();
674 assert_eq!(r, Err(ParseError::Empty));
675 }
676
677 #[test]
678 fn from_str_sign_only_is_err() {
679 assert_eq!("-".parse::<D38s12>(), Err(ParseError::SignOnly));
680 assert_eq!("+".parse::<D38s12>(), Err(ParseError::SignOnly));
681 }
682
683 #[test]
684 fn from_str_leading_zero_is_err() {
685 assert_eq!("01".parse::<D38s12>(), Err(ParseError::LeadingZero));
686 assert_eq!(
687 "01.5".parse::<D38s12>(),
688 Err(ParseError::LeadingZero)
689 );
690 assert_eq!("00".parse::<D38s12>(), Err(ParseError::LeadingZero));
691 }
692
693 #[test]
694 fn from_str_overlong_fractional_is_err() {
695 // SCALE 12, fractional length 13 -> reject.
696 let r: Result<D38s12, _> = "0.1234567890123".parse();
697 assert_eq!(r, Err(ParseError::OverlongFractional));
698 }
699
700 #[test]
701 fn from_str_scientific_notation_is_err() {
702 assert_eq!(
703 "1e3".parse::<D38s12>(),
704 Err(ParseError::ScientificNotation)
705 );
706 assert_eq!(
707 "1.5E2".parse::<D38s12>(),
708 Err(ParseError::ScientificNotation)
709 );
710 }
711
712 #[test]
713 fn from_str_invalid_char_is_err() {
714 assert_eq!(
715 "garbage".parse::<D38s12>(),
716 Err(ParseError::InvalidChar)
717 );
718 assert_eq!(
719 "1.2x".parse::<D38s12>(),
720 Err(ParseError::InvalidChar)
721 );
722 assert_eq!(
723 "1..2".parse::<D38s12>(),
724 Err(ParseError::InvalidChar)
725 );
726 }
727
728 #[test]
729 fn from_str_missing_digits_is_err() {
730 assert_eq!(
731 ".5".parse::<D38s12>(),
732 Err(ParseError::MissingDigits)
733 );
734 assert_eq!(
735 "5.".parse::<D38s12>(),
736 Err(ParseError::MissingDigits)
737 );
738 assert_eq!(
739 "-.5".parse::<D38s12>(),
740 Err(ParseError::MissingDigits)
741 );
742 }
743
744 #[test]
745 fn from_str_out_of_range_is_err() {
746 // 10^39 > i128::MAX (~1.7e38). At SCALE 12, the maximum
747 // integer part is i128::MAX / 10^12 ~= 1.7e26, so an integer
748 // part of 1e27 already overflows.
749 let r: Result<D38s12, _> = "1000000000000000000000000000".parse();
750 assert_eq!(r, Err(ParseError::OutOfRange));
751 }
752
753 /// Parse exactly at i128::MIN -- the asymmetric two's-complement
754 /// boundary. At SCALE 12:
755 /// `i128::MIN = -170141183460469231731687303715884105728`
756 /// which splits into integer `170141183460469231731687303` and
757 /// fractional `715884105728` (the negative form has the same
758 /// digits since |MIN| = MAX + 1).
759 #[test]
760 fn from_str_i128_min_boundary() {
761 let s = "-170141183460469231731687303.715884105728";
762 let v: D38s12 = s.parse().unwrap();
763 assert_eq!(v.to_bits(), i128::MIN);
764 }
765
766 /// Parse exactly at i128::MAX boundary. At SCALE 12 the canonical
767 /// form is `170141183460469231731687303.715884105727`.
768 #[test]
769 fn from_str_i128_max_boundary() {
770 let s = "170141183460469231731687303.715884105727";
771 let v: D38s12 = s.parse().unwrap();
772 assert_eq!(v.to_bits(), i128::MAX);
773 }
774
775 /// One-past-MAX positive overflows.
776 #[test]
777 fn from_str_just_above_max_overflows() {
778 // ...728 is one fractional LSB above i128::MAX.
779 let s = "170141183460469231731687303.715884105728";
780 let r: Result<D38s12, _> = s.parse();
781 assert_eq!(r, Err(ParseError::OutOfRange));
782 }
783
784 // ── Property tests: parse(value.to_string()) round-trip ──
785
786 /// Round-trip property for representative storage values.
787 /// Uses safe-decimal-test-values (no clippy approx_constant traps).
788 #[cfg(feature = "alloc")]
789 #[test]
790 fn round_trip_representative_values() {
791 let cases: &[i128] = &[
792 0,
793 1,
794 -1,
795 1_000_000_000_000, // 1.0
796 -1_000_000_000_000,
797 1_500_000_000_000, // 1.5
798 -1_500_000_000_000,
799 1_100_000_000_000, // 1.1 (the headline base-10 claim)
800 2_200_000_000_000, // 2.2
801 3_300_000_000_000, // 3.3
802 // Safe arbitrary-looking literal (avoids approx_constant
803 // triggers like 3.14, 2.718, 1.414 etc.):
804 1_234_567_890_123, // ~1.234567890123
805 -1_234_567_890_123,
806 4_567_891_234_567, // ~4.567891234567
807 7_890_123_456_789, // ~7.890123456789
808 i128::MAX,
809 i128::MIN,
810 i128::MAX / 2,
811 i128::MIN / 2,
812 ];
813 for &raw in cases {
814 let v = D38s12::from_bits(raw);
815 let s = v.to_string();
816 let parsed: D38s12 = s.parse().unwrap_or_else(|e| {
817 panic!("round-trip parse failed for raw={raw}, s={s:?}, err={e:?}")
818 });
819 assert_eq!(
820 parsed.to_bits(),
821 raw,
822 "round-trip mismatch: raw={raw}, s={s:?}, parsed_bits={}",
823 parsed.to_bits()
824 );
825 }
826 }
827
828 /// Round-trip property at SCALE = 6 to exercise the const-generic
829 /// path away from the v1 SCALE = 12.
830 #[cfg(feature = "alloc")]
831 #[test]
832 fn round_trip_other_scale() {
833 type D6 = D38<6>;
834 let cases: &[i128] = &[
835 0,
836 1,
837 -1,
838 1_000_000,
839 -1_000_000,
840 1_500_000,
841 i128::MAX,
842 i128::MIN,
843 ];
844 for &raw in cases {
845 let v = D6::from_bits(raw);
846 let s = v.to_string();
847 let parsed: D6 = s.parse().expect("round-trip parse");
848 assert_eq!(
849 parsed.to_bits(),
850 raw,
851 "round-trip mismatch at SCALE=6, raw={raw}"
852 );
853 }
854 }
855
856 /// Round-trip at SCALE = 0 (integer-only) to exercise the
857 /// no-decimal-point path.
858 #[cfg(feature = "alloc")]
859 #[test]
860 fn round_trip_scale_zero() {
861 type D0 = D38<0>;
862 let cases: &[i128] = &[0, 1, -1, 42, -42, i128::MAX, i128::MIN];
863 for &raw in cases {
864 let v = D0::from_bits(raw);
865 let s = v.to_string();
866 let parsed: D0 = s.parse().expect("round-trip parse");
867 assert_eq!(
868 parsed.to_bits(),
869 raw,
870 "round-trip mismatch at SCALE=0, raw={raw}"
871 );
872 }
873 }
874}