Skip to main content

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}