Skip to main content

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