Skip to main content

decimal_scaled/
serde_helpers.rs

1//! `serde` integration for every decimal width.
2//!
3//! D38 has a dedicated [`Serialize`] / [`Deserialize`] pair plus the
4//! richer [`decimal_serde::DecimalVisitor`] used for `#[serde(with =
5//! "...")]` field annotations. The wide tiers (D76 / D153 / D307)
6//! use a slimmer implementation emitted by `decl_wide_serde!`: a
7//! decimal-string wire format for human-readable serializers and a
8//! little-endian limb-bytes wire format for binary serializers.
9//! Cross-tier wire-format parity is intentional — a D38 produced
10//! at SCALE = 12 serialises to the same string as a D76 at SCALE =
11//! 12 carrying the same logical value.
12//!
13//!
14//! # Wire format
15//!
16//! `D38<SCALE>` chooses its wire encoding based on the serializer's
17//! [`serde::Serializer::is_human_readable`] flag:
18//!
19//! - **Human-readable formats** (JSON, TOML, YAML): a base-10 integer
20//! string of the underlying `i128` storage value. For example,
21//! `D38s12::ONE` (storage `1_000_000_000_000`) serialises as the
22//! JSON string `"1000000000000"`. This is not a decimal string like
23//! `"1.0"` — that is the job of `Display`, not the wire format.
24//!
25//! A string rather than a JSON number is used because JSON numbers
26//! are effectively `f64` in most runtimes (max safe integer =
27//! `2^53 - 1`), while `i128` storage requires up to 127 bits. A
28//! BigInt-compatible integer string is the only lossless option for
29//! interoperability with JavaScript, where `BigInt(s).toString()`
30//! round-trips the same digits.
31//!
32//! - **Binary formats** (postcard, bincode, etc.): 16 little-endian
33//! bytes from `i128::to_le_bytes`. Compact and endian-canonical.
34//!
35//! On deserialise, the internal `DecimalVisitor` handles both wire forms plus
36//! `visit_i64` / `visit_u64` / `visit_i128` / `visit_u128` callbacks,
37//! which are used when the underlying format yields a native integer.
38//! The integer is interpreted directly as the scaled `i128` storage.
39
40use core::marker::PhantomData;
41
42use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer};
43
44#[cfg(feature = "alloc")]
45use alloc::string::ToString;
46
47use crate::core_type::D38;
48
49// ── Serialize ─────────────────────────────────────────────────────────
50
51impl<const SCALE: u32> Serialize for D38<SCALE> {
52    /// Serialise `self` as a base-10 integer string for human-readable
53    /// formats, or as 16 little-endian bytes for binary formats.
54    ///
55    /// # Precision
56    ///
57    /// Strict: all arithmetic is integer-only; result is bit-exact.
58    #[inline]
59    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
60        if serializer.is_human_readable() {
61            // Formatting an i128 as a decimal string requires heap
62            // allocation. Every real human-readable format already
63            // depends on alloc, so this is not a practical constraint.
64            #[cfg(feature = "alloc")]
65            {
66                serializer.serialize_str(&self.0.to_string())
67            }
68            // Human-readable serialisation without alloc is not
69            // supported. A 40-byte stack buffer would technically
70            // suffice for an i128 decimal, but no real target combines
71            // `no_std + !alloc + serde + human-readable format`.
72            #[cfg(not(feature = "alloc"))]
73            {
74                let _ = serializer;
75                Err(serde::ser::Error::custom(
76                    "decimal-scaled: human-readable serialisation requires the `alloc` feature",
77                ))
78            }
79        } else {
80            // Binary path: emit the raw i128 as 16 little-endian bytes.
81            serializer.serialize_bytes(&self.0.to_le_bytes())
82        }
83    }
84}
85
86// ── Deserialize ───────────────────────────────────────────────────────
87
88impl<'de, const SCALE: u32> Deserialize<'de> for D38<SCALE> {
89    /// Deserialise from a base-10 integer string (human-readable
90    /// formats), 16 little-endian bytes (binary formats), or a native
91    /// integer (self-describing binary formats such as CBOR).
92    ///
93    /// Human-readable formats route via `deserialize_any` so a JSON
94    /// string, JSON number, or TOML integer all reach the correct
95    /// visitor branch. Binary formats that are not self-describing
96    /// (postcard, bincode) route via `deserialize_bytes` directly.
97    ///
98    /// # Precision
99    ///
100    /// Strict: all arithmetic is integer-only; result is bit-exact.
101    #[inline]
102    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
103        let visitor = decimal_serde::DecimalVisitor::<SCALE>(PhantomData);
104        if deserializer.is_human_readable() {
105            deserializer.deserialize_any(visitor)
106        } else {
107            deserializer.deserialize_bytes(visitor)
108        }
109    }
110}
111
112// ── Free-function helpers and visitor ─────────────────────────────────
113
114/// Serde helper module for `#[serde(with = "...")]` field annotations.
115///
116/// Use this module when you want to control serialisation of a `D38`
117/// field on a struct that derives `Serialize` / `Deserialize`:
118///
119/// ```ignore
120/// use decimal_scaled::D38;
121///
122/// #[derive(serde::Serialize, serde::Deserialize)]
123/// struct MyStruct {
124/// #[serde(with = "decimal_scaled::serde_helpers::decimal_serde")]
125/// length: D38<12>,
126/// }
127/// ```
128///
129/// The free functions delegate to the inherent `Serialize` /
130/// `Deserialize` impls; they exist so users can annotate
131/// `#[serde(with = ...)]` on fields in generic containing types or in
132/// newtype wrappers where the trait impl may be shadowed.
133pub mod decimal_serde {
134    use super::{Serializer, D38, Serialize, Deserializer, Deserialize, PhantomData, Visitor};
135
136    /// Serialise `v` using the `D38` wire format.
137    ///
138    /// Intended for use under `#[serde(serialize_with = "...")]` or
139    /// `#[serde(with = "...")]`.
140    ///
141    /// # Precision
142    ///
143    /// Strict: all arithmetic is integer-only; result is bit-exact.
144    #[inline]
145    pub fn serialize<const SCALE: u32, S: Serializer>(
146        v: &D38<SCALE>,
147        s: S,
148    ) -> Result<S::Ok, S::Error> {
149        v.serialize(s)
150    }
151
152    /// Deserialise a `D38` using the wire format.
153    ///
154    /// Intended for use under `#[serde(deserialize_with = "...")]` or
155    /// `#[serde(with = "...")]`.
156    ///
157    /// # Precision
158    ///
159    /// Strict: all arithmetic is integer-only; result is bit-exact.
160    #[inline]
161    pub fn deserialize<'de, const SCALE: u32, D: Deserializer<'de>>(
162        d: D,
163    ) -> Result<D38<SCALE>, D::Error> {
164        D38::<SCALE>::deserialize(d)
165    }
166
167    /// Visitor that backs [`deserialize`]. Public so external helper
168    /// modules can reuse it under custom `#[serde(deserialize_with)]`
169    /// shapes.
170    ///
171    /// Accepted inputs:
172    ///
173    /// - `&str` / borrowed string: parsed as a strict base-10 `i128`
174    /// integer (no decimal point, no whitespace, no leading `+`).
175    /// - `&[u8]` / byte buf: interpreted as exactly 16 little-endian
176    /// `i128` bytes.
177    /// - Native integer (`i8` through `i128`, `u8` through `u128`):
178    /// widened into `i128` storage directly. The integer is treated as
179    /// the scaled storage value, not as a logical decimal value.
180    pub struct DecimalVisitor<const SCALE: u32>(pub PhantomData<()>);
181
182    impl<'de, const SCALE: u32> Visitor<'de> for DecimalVisitor<SCALE> {
183        type Value = D38<SCALE>;
184
185        fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
186            f.write_str(
187                "a base-10 i128 integer string, 16 little-endian bytes, \
188                 or a native integer",
189            )
190        }
191
192        // ── String wire form (human-readable) ─────────────────────────
193
194        fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
195            // The wire format is a strict base-10 i128 integer literal
196            // matching `-?[0-9]+`. No whitespace, no leading `+`, no
197            // decimal point, no scientific notation, no underscores.
198            // Display's canonical decimal form (e.g. "1.500") is NOT
199            // accepted here — that belongs to the FromStr parse path.
200            //
201            // A leading `+` is rejected explicitly to keep one canonical
202            // wire form per value, matching JavaScript BigInt.toString()
203            // output which is never `+`-prefixed.
204            let bytes = v.as_bytes();
205            if bytes.is_empty() {
206                return Err(serde::de::Error::custom(
207                    "decimal-scaled: empty string is not a valid i128 wire",
208                ));
209            }
210            if bytes[0] == b'+' {
211                return Err(serde::de::Error::custom(
212                    "decimal-scaled: leading `+` is not part of the canonical wire format",
213                ));
214            }
215            v.parse::<i128>()
216                .map(D38::<SCALE>::from_bits)
217                .map_err(|_| {
218                    serde::de::Error::custom(
219                        "decimal-scaled: expected a base-10 i128 integer string",
220                    )
221                })
222        }
223
224        fn visit_borrowed_str<E: serde::de::Error>(self, v: &'de str) -> Result<Self::Value, E> {
225            self.visit_str(v)
226        }
227
228        #[cfg(feature = "alloc")]
229        fn visit_string<E: serde::de::Error>(
230            self,
231            v: alloc::string::String,
232        ) -> Result<Self::Value, E> {
233            self.visit_str(&v)
234        }
235
236        // ── Bytes wire form (binary) ───────────────────────────────────
237
238        fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Self::Value, E> {
239            // Require exactly 16 bytes: the little-endian i128 layout.
240            let arr: [u8; 16] = v.try_into().map_err(|_| {
241                serde::de::Error::invalid_length(
242                    v.len(),
243                    &"exactly 16 little-endian bytes for an i128",
244                )
245            })?;
246            Ok(D38::<SCALE>::from_bits(i128::from_le_bytes(arr)))
247        }
248
249        fn visit_borrowed_bytes<E: serde::de::Error>(
250            self,
251            v: &'de [u8],
252        ) -> Result<Self::Value, E> {
253            self.visit_bytes(v)
254        }
255
256        #[cfg(feature = "alloc")]
257        fn visit_byte_buf<E: serde::de::Error>(
258            self,
259            v: alloc::vec::Vec<u8>,
260        ) -> Result<Self::Value, E> {
261            self.visit_bytes(&v)
262        }
263
264        // ── Native-integer wire forms ──────────────────────────────────
265        //
266        // These branches are entered when the underlying format yields a
267        // typed integer rather than a string or byte slice (e.g. CBOR
268        // major types 0/1, MessagePack integer family). The value is
269        // interpreted as the scaled i128 storage, matching the binary
270        // serialise path.
271
272        fn visit_i8<E: serde::de::Error>(self, v: i8) -> Result<Self::Value, E> {
273            Ok(D38::<SCALE>::from_bits(i128::from(v)))
274        }
275
276        fn visit_i16<E: serde::de::Error>(self, v: i16) -> Result<Self::Value, E> {
277            Ok(D38::<SCALE>::from_bits(i128::from(v)))
278        }
279
280        fn visit_i32<E: serde::de::Error>(self, v: i32) -> Result<Self::Value, E> {
281            Ok(D38::<SCALE>::from_bits(i128::from(v)))
282        }
283
284        fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
285            Ok(D38::<SCALE>::from_bits(i128::from(v)))
286        }
287
288        fn visit_i128<E: serde::de::Error>(self, v: i128) -> Result<Self::Value, E> {
289            Ok(D38::<SCALE>::from_bits(v))
290        }
291
292        fn visit_u8<E: serde::de::Error>(self, v: u8) -> Result<Self::Value, E> {
293            Ok(D38::<SCALE>::from_bits(i128::from(v)))
294        }
295
296        fn visit_u16<E: serde::de::Error>(self, v: u16) -> Result<Self::Value, E> {
297            Ok(D38::<SCALE>::from_bits(i128::from(v)))
298        }
299
300        fn visit_u32<E: serde::de::Error>(self, v: u32) -> Result<Self::Value, E> {
301            Ok(D38::<SCALE>::from_bits(i128::from(v)))
302        }
303
304        fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
305            Ok(D38::<SCALE>::from_bits(i128::from(v)))
306        }
307
308        fn visit_u128<E: serde::de::Error>(self, v: u128) -> Result<Self::Value, E> {
309            // u128 values above i128::MAX cannot be represented; reject
310            // explicitly rather than wrapping silently.
311            i128::try_from(v).map(D38::<SCALE>::from_bits).map_err(|_| {
312                serde::de::Error::custom(
313                    "decimal-scaled: u128 value exceeds i128 storage range",
314                )
315            })
316        }
317
318        // ── Float inputs are not a supported wire format ───────────────
319        //
320        // The wire format is integer-string or little-endian bytes.
321        // Floats are not accepted. If a human-edited TOML file contains
322        // a bare integer that fits in i64, the format's deserializer
323        // routes via visit_i64 / visit_u64 / visit_i128 above, which is
324        // correct. A genuine f64 value (e.g. 1.5) is rejected as
325        // "expected i128 integer".
326    }
327}
328
329// ── Tests ──────────────────────────────────────────────────────────────
330
331#[cfg(all(test, feature = "alloc", feature = "serde"))]
332mod tests {
333    use super::*;
334    use crate::core_type::{D38, D38s12};
335    use serde::de::value::{Error as DeError, StrDeserializer};
336    use serde::de::IntoDeserializer;
337    use alloc::format;
338
339    // ── String wire form round-trips ──────────────────────────────────
340
341    /// `"0"` deserialises to `ZERO` via the canonical-string path.
342    #[test]
343    fn deserialize_canonical_zero_string() {
344        let de: StrDeserializer<DeError> = "0".into_deserializer();
345        let v: D38s12 = D38s12::deserialize(de).unwrap();
346        assert_eq!(v, D38s12::ZERO);
347    }
348
349    /// The visitor accepts the scaled integer representation of `ONE`
350    /// (`10^12` for `D38s12`) when fed via `visit_str`.
351    #[test]
352    fn visitor_accepts_scaled_one_str() {
353        let visitor = decimal_serde::DecimalVisitor::<12>(PhantomData);
354        let v: D38s12 =
355            <_ as Visitor>::visit_str::<DeError>(visitor, "1000000000000").unwrap();
356        assert_eq!(v, D38s12::ONE);
357    }
358
359    /// The visitor rejects a decimal-point string. `"1.5"` is the
360    /// Display format, not the wire format.
361    #[test]
362    fn visitor_rejects_decimal_point_str() {
363        let visitor = decimal_serde::DecimalVisitor::<12>(PhantomData);
364        let res: Result<D38s12, _> =
365            <_ as Visitor>::visit_str::<DeError>(visitor, "1.5");
366        assert!(res.is_err(), "expected reject; got Ok({:?})", res);
367    }
368
369    // ── Native-integer wire form round-trips ──────────────────────────
370
371    /// `visit_i64` interprets the input as scaled storage; `-5` stored
372    /// directly produces a `D38` whose raw bits are `-5`.
373    #[test]
374    fn visitor_accepts_i64_as_storage() {
375        let visitor = decimal_serde::DecimalVisitor::<12>(PhantomData);
376        let v: D38s12 = <_ as Visitor>::visit_i64::<DeError>(visitor, -5).unwrap();
377        assert_eq!(v.to_bits(), -5);
378    }
379
380    /// `visit_u64` with `u64::MAX` widens cleanly into `i128` storage.
381    #[test]
382    fn visitor_accepts_u64_max() {
383        let visitor = decimal_serde::DecimalVisitor::<12>(PhantomData);
384        let v: D38s12 =
385            <_ as Visitor>::visit_u64::<DeError>(visitor, u64::MAX).unwrap();
386        assert_eq!(v.to_bits(), u64::MAX as i128);
387    }
388
389    /// `visit_u128` past `i128::MAX` yields an explicit out-of-range
390    /// error rather than wrapping silently.
391    #[test]
392    fn visitor_rejects_u128_above_i128_max() {
393        let visitor = decimal_serde::DecimalVisitor::<12>(PhantomData);
394        let res: Result<D38s12, _> = <_ as Visitor>::visit_u128::<DeError>(
395            visitor,
396            (i128::MAX as u128) + 1,
397        );
398        assert!(res.is_err(), "expected overflow reject; got Ok({:?})", res);
399    }
400
401    // ── JSON round-trips ──────────────────────────────────────────────
402
403    /// `D38s12::ONE` serialises as the JSON string `"1000000000000"`.
404    /// This is the BigInt-compatible wire form, not the Display form
405    /// `"1.000000000000"`.
406    #[test]
407    fn json_one_serialises_as_scaled_integer_string() {
408        let json = serde_json::to_string(&D38s12::ONE).unwrap();
409        assert_eq!(json, "\"1000000000000\"");
410    }
411
412    #[test]
413    fn json_zero_serialises_as_zero_string() {
414        let json = serde_json::to_string(&D38s12::ZERO).unwrap();
415        assert_eq!(json, "\"0\"");
416    }
417
418    #[test]
419    fn json_one_round_trips() {
420        let json = serde_json::to_string(&D38s12::ONE).unwrap();
421        let back: D38s12 = serde_json::from_str(&json).unwrap();
422        assert_eq!(back, D38s12::ONE);
423    }
424
425    #[test]
426    fn json_zero_round_trips() {
427        let json = serde_json::to_string(&D38s12::ZERO).unwrap();
428        let back: D38s12 = serde_json::from_str(&json).unwrap();
429        assert_eq!(back, D38s12::ZERO);
430    }
431
432    /// Negative values round-trip through JSON. `from(-5_i32)` stores
433    /// `-5 * 10^12 = -5_000_000_000_000`.
434    #[test]
435    fn json_negative_round_trips() {
436        let v = D38s12::from(-5_i32);
437        let json = serde_json::to_string(&v).unwrap();
438        assert_eq!(json, "\"-5000000000000\"");
439        let back: D38s12 = serde_json::from_str(&json).unwrap();
440        assert_eq!(back, v);
441        assert_eq!(back.to_bits(), -5_000_000_000_000_i128);
442    }
443
444    /// `D38::MAX` and `D38::MIN` round-trip exactly through the
445    /// JSON-string wire format.
446    #[test]
447    fn json_max_round_trips() {
448        let json = serde_json::to_string(&D38s12::MAX).unwrap();
449        let back: D38s12 = serde_json::from_str(&json).unwrap();
450        assert_eq!(back, D38s12::MAX);
451    }
452
453    #[test]
454    fn json_min_round_trips() {
455        let json = serde_json::to_string(&D38s12::MIN).unwrap();
456        let back: D38s12 = serde_json::from_str(&json).unwrap();
457        assert_eq!(back, D38s12::MIN);
458    }
459
460    /// The JSON string representation matches `i128::to_string` exactly.
461    /// On the JavaScript side, `BigInt(json).toString()` reproduces the
462    /// same digits.
463    #[test]
464    fn json_string_matches_i128_to_string() {
465        let raw: i128 = -123_456_789_012_345_678_901_234_567_890_i128;
466        let v = D38s12::from_bits(raw);
467        let json = serde_json::to_string(&v).unwrap();
468        assert_eq!(json, format!("\"{}\"", raw));
469    }
470
471    // ── JSON: malformed input rejection ───────────────────────────────
472
473    #[test]
474    fn json_rejects_decimal_point_string() {
475        let res: Result<D38s12, _> = serde_json::from_str("\"1.5\"");
476        assert!(res.is_err(), "expected reject; got Ok({:?})", res);
477    }
478
479    #[test]
480    fn json_rejects_scientific_notation_string() {
481        let res: Result<D38s12, _> = serde_json::from_str("\"1e6\"");
482        assert!(res.is_err(), "expected reject; got Ok({:?})", res);
483    }
484
485    #[test]
486    fn json_rejects_not_a_number_string() {
487        let res: Result<D38s12, _> = serde_json::from_str("\"not-a-number\"");
488        assert!(res.is_err(), "expected reject; got Ok({:?})", res);
489    }
490
491    #[test]
492    fn json_rejects_empty_string() {
493        let res: Result<D38s12, _> = serde_json::from_str("\"\"");
494        assert!(res.is_err(), "expected reject; got Ok({:?})", res);
495    }
496
497    #[test]
498    fn json_rejects_leading_whitespace_string() {
499        // `i128::from_str` does not trim whitespace; the wire format
500        // requires a strict integer literal.
501        let res: Result<D38s12, _> = serde_json::from_str("\"  42\"");
502        assert!(res.is_err(), "expected reject; got Ok({:?})", res);
503    }
504
505    #[test]
506    fn json_rejects_plus_prefix() {
507        let res: Result<D38s12, _> = serde_json::from_str("\"+42\"");
508        assert!(res.is_err(), "expected reject; got Ok({:?})", res);
509    }
510
511    /// A bare JSON integer (not a string) is accepted via `visit_i64`.
512    /// The number is interpreted as the scaled storage value.
513    #[test]
514    fn json_accepts_bare_integer_number_as_storage() {
515        let back: D38s12 = serde_json::from_str("42").unwrap();
516        assert_eq!(back.to_bits(), 42_i128);
517    }
518
519    // ── Postcard binary 16-byte LE round-trips ────────────────────────
520
521    #[test]
522    fn postcard_one_round_trips() {
523        let bytes: alloc::vec::Vec<u8> = postcard::to_allocvec(&D38s12::ONE).unwrap();
524        // Verify the raw 16 LE bytes appear somewhere in the postcard
525        // output (postcard may prepend a varint length prefix).
526        let raw = D38s12::ONE.to_bits().to_le_bytes();
527        assert!(bytes.windows(16).any(|w| w == raw));
528        let back: D38s12 = postcard::from_bytes(&bytes).unwrap();
529        assert_eq!(back, D38s12::ONE);
530    }
531
532    #[test]
533    fn postcard_zero_round_trips() {
534        let bytes: alloc::vec::Vec<u8> = postcard::to_allocvec(&D38s12::ZERO).unwrap();
535        let back: D38s12 = postcard::from_bytes(&bytes).unwrap();
536        assert_eq!(back, D38s12::ZERO);
537    }
538
539    #[test]
540    fn postcard_negative_round_trips() {
541        let v = D38s12::from(-5_i32);
542        let bytes: alloc::vec::Vec<u8> = postcard::to_allocvec(&v).unwrap();
543        let back: D38s12 = postcard::from_bytes(&bytes).unwrap();
544        assert_eq!(back, v);
545    }
546
547    #[test]
548    fn postcard_max_round_trips() {
549        let bytes: alloc::vec::Vec<u8> = postcard::to_allocvec(&D38s12::MAX).unwrap();
550        let back: D38s12 = postcard::from_bytes(&bytes).unwrap();
551        assert_eq!(back, D38s12::MAX);
552    }
553
554    #[test]
555    fn postcard_min_round_trips() {
556        let bytes: alloc::vec::Vec<u8> = postcard::to_allocvec(&D38s12::MIN).unwrap();
557        let back: D38s12 = postcard::from_bytes(&bytes).unwrap();
558        assert_eq!(back, D38s12::MIN);
559    }
560
561    /// The postcard payload contains the raw `i128::to_le_bytes`
562    /// representation. The first LE byte is the LSB and the last is
563    /// the MSB.
564    #[test]
565    fn postcard_byte_order_matches_le() {
566        let v = D38s12::from_bits(0x0123_4567_89AB_CDEF_FEDC_BA98_7654_3210_i128);
567        let bytes: alloc::vec::Vec<u8> = postcard::to_allocvec(&v).unwrap();
568        let raw = v.to_bits().to_le_bytes();
569        let found = bytes.windows(16).position(|w| w == raw);
570        assert!(found.is_some(), "expected raw LE bytes embedded; got {:?}", bytes);
571        assert_eq!(raw[0], 0x10);  // LSB of the i128
572        assert_eq!(raw[15], 0x01); // MSB of the i128
573    }
574
575    // ── Cross-format compatibility ─────────────────────────────────────
576
577    /// The JSON integer string, when parsed back to `i128` and converted
578    /// to `to_le_bytes`, matches the binary wire representation directly.
579    #[test]
580    fn cross_format_json_string_matches_le_bytes() {
581        let v = D38s12::from(42_i32);
582        let json = serde_json::to_string(&v).unwrap();
583        let inner = json.trim_matches('"');
584        let parsed: i128 = inner.parse().unwrap();
585        let json_bytes = parsed.to_le_bytes();
586        let direct_bytes = v.to_bits().to_le_bytes();
587        assert_eq!(json_bytes, direct_bytes);
588    }
589
590    /// Different SCALE values serialise identically when they share the
591    /// same raw storage. The SCALE is a compile-time type parameter and
592    /// is not encoded in the wire.
593    #[test]
594    fn cross_scale_wire_is_storage_only() {
595        let raw: i128 = 1_500_000_000_000;
596        let v12 = D38::<12>::from_bits(raw);
597        let v6 = D38::<6>::from_bits(raw);
598        assert_eq!(serde_json::to_string(&v12).unwrap(), "\"1500000000000\"");
599        assert_eq!(serde_json::to_string(&v6).unwrap(), "\"1500000000000\"");
600    }
601
602    // ── decimal_serde free-function helpers ───────────────────────────
603
604    /// The `#[serde(with = "...")]` helpers delegate to the inherent
605    /// impls and produce the correct JSON output.
606    #[test]
607    fn decimal_serde_helper_round_trips() {
608        #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
609        struct Holder {
610            #[serde(with = "crate::serde_helpers::decimal_serde")]
611            length: D38<12>,
612        }
613
614        let h = Holder {
615            length: D38s12::from(7_i32),
616        };
617        let json = serde_json::to_string(&h).unwrap();
618        assert_eq!(json, r#"{"length":"7000000000000"}"#);
619        let back: Holder = serde_json::from_str(&json).unwrap();
620        assert_eq!(back, h);
621    }
622}
623
624// ─── Wide-tier serde (D76 / D153 / D307) ────────────────────────────
625//
626// The wide-tier wire format mirrors D38's: a base-10 integer string
627// of the raw storage value for human-readable serializers, and the
628// raw little-endian limb bytes for binary serializers. The
629// implementation is intentionally slimmer than D38's — no
630// native-integer visit methods, since no native int can losslessly
631// carry the >128-bit storage anyway.
632
633/// Emits `Serialize` / `Deserialize` for a wide-tier decimal type
634/// (D76 / D153 / D307). `$bytes_len` is `mem::size_of::<$Storage>()`
635/// (e.g. 32 for `Int256`).
636#[cfg(any(feature = "d76", feature = "d153", feature = "d307", feature = "wide", feature = "x-wide"))]
637macro_rules! decl_wide_serde {
638    ($Type:ident, $Storage:ty, $bytes_len:literal) => {
639        impl<const SCALE: u32> Serialize for $crate::core_type::$Type<SCALE> {
640            /// Serialise as a base-10 integer string for human-
641            /// readable formats, or as `$bytes_len` little-endian
642            /// bytes for binary formats.
643            #[inline]
644            fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
645                if s.is_human_readable() {
646                    #[cfg(feature = "alloc")]
647                    {
648                        s.serialize_str(&self.0.to_string())
649                    }
650                    #[cfg(not(feature = "alloc"))]
651                    {
652                        let _ = s;
653                        Err(serde::ser::Error::custom(
654                            "decimal-scaled: human-readable serialisation requires `alloc`",
655                        ))
656                    }
657                } else {
658                    let mut bytes = [0u8; $bytes_len];
659                    let limbs = self.0.limbs_le();
660                    for (i, limb) in limbs.iter().enumerate() {
661                        bytes[i * 16..(i + 1) * 16].copy_from_slice(&limb.to_le_bytes());
662                    }
663                    s.serialize_bytes(&bytes)
664                }
665            }
666        }
667
668        impl<'de, const SCALE: u32> Deserialize<'de> for $crate::core_type::$Type<SCALE> {
669            #[inline]
670            fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
671                struct V<const S: u32>;
672                impl<'de, const S: u32> Visitor<'de> for V<S> {
673                    type Value = $crate::core_type::$Type<S>;
674                    fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
675                        f.write_str(concat!(
676                            "a base-10 integer string or ",
677                            stringify!($bytes_len),
678                            " little-endian bytes for ",
679                            stringify!($Type),
680                        ))
681                    }
682                    fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
683                        let parsed = <$Storage>::from_str_radix(v, 10).map_err(|_| {
684                            serde::de::Error::custom(concat!(
685                                stringify!($Type),
686                                ": invalid base-10 integer string",
687                            ))
688                        })?;
689                        Ok(<$crate::core_type::$Type<S>>::from_bits(parsed))
690                    }
691                    fn visit_borrowed_str<E: serde::de::Error>(self, v: &'de str) -> Result<Self::Value, E> {
692                        self.visit_str(v)
693                    }
694                    #[cfg(feature = "alloc")]
695                    fn visit_string<E: serde::de::Error>(self, v: alloc::string::String) -> Result<Self::Value, E> {
696                        self.visit_str(&v)
697                    }
698                    fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Self::Value, E> {
699                        if v.len() != $bytes_len {
700                            return Err(serde::de::Error::invalid_length($bytes_len, &self));
701                        }
702                        let mut limbs = [0u128; $bytes_len / 16];
703                        for (i, limb) in limbs.iter_mut().enumerate() {
704                            let mut buf = [0u8; 16];
705                            buf.copy_from_slice(&v[i * 16..(i + 1) * 16]);
706                            *limb = u128::from_le_bytes(buf);
707                        }
708                        Ok(<$crate::core_type::$Type<S>>::from_bits(<$Storage>::from_limbs_le(limbs)))
709                    }
710                    fn visit_borrowed_bytes<E: serde::de::Error>(self, v: &'de [u8]) -> Result<Self::Value, E> {
711                        self.visit_bytes(v)
712                    }
713                }
714                if d.is_human_readable() {
715                    d.deserialize_str(V::<SCALE>)
716                } else {
717                    d.deserialize_bytes(V::<SCALE>)
718                }
719            }
720        }
721    };
722}
723
724#[cfg(any(feature = "d76", feature = "wide"))]
725decl_wide_serde!(D76, crate::wide_int::Int256, 32);
726#[cfg(any(feature = "d153", feature = "wide"))]
727decl_wide_serde!(D153, crate::wide_int::Int512, 64);
728#[cfg(any(feature = "d307", feature = "wide"))]
729decl_wide_serde!(D307, crate::wide_int::Int1024, 128);
730
731#[cfg(all(test, feature = "wide"))]
732mod wide_serde_tests {
733    use crate::D76;
734
735    #[test]
736    fn d76_human_readable_round_trip() {
737        let v = D76::<12>::from_int(1_234_567_i128);
738        let json = serde_json::to_string(&v).unwrap();
739        let back: D76<12> = serde_json::from_str(&json).unwrap();
740        assert_eq!(back, v);
741    }
742
743    #[test]
744    fn d76_negative_human_readable_round_trip() {
745        let v = -D76::<12>::from_int(987_654_321_i128);
746        let json = serde_json::to_string(&v).unwrap();
747        let back: D76<12> = serde_json::from_str(&json).unwrap();
748        assert_eq!(back, v);
749    }
750
751    #[test]
752    fn d76_binary_round_trip() {
753        // postcard is a binary, non-self-describing format.
754        let v = D76::<12>::from_int(42_i128);
755        let bytes = postcard::to_allocvec(&v).unwrap();
756        let back: D76<12> = postcard::from_bytes(&bytes).unwrap();
757        assert_eq!(back, v);
758    }
759}