Skip to main content

decimal_scaled/
serde_helpers.rs

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