oracledb-protocol 0.5.1

Sans-I/O Oracle TNS/TTC protocol core for the oracledb crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
#![forbid(unsafe_code)]

//! Inline, lossless Oracle `NUMBER` representation (bead rust-oracledb-65w).
//!
//! Oracle `NUMBER` is up to 40 significant decimal digits (the wire form carries
//! up to 20 base-100 mantissa bytes) with a decimal exponent in roughly
//! `-130..=125`. The common case — a value with at most 38 significant digits —
//! fits losslessly in an `i128` coefficient plus an `i16` scale, allocating
//! nothing. The owned [`crate::thin::QueryValue::Number`] used to carry a heap
//! `String` per cell; this module replaces that inline payload so a NUMBER-heavy
//! row stops doing one `malloc` per NUMBER column.
//!
//! ## Losslessness
//!
//! Some wire forms cannot be represented exactly inline:
//!
//! - A 39- or 40-digit integer can exceed `i128::MAX` (`~1.7e38`, 39 digits).
//! - The decoder's special single-byte negative sentinel renders as the literal
//!   text `-1e126`, which is not a plain `coefficient × 10^-scale` decimal.
//!
//! For any such value the representation FALLS BACK to a boxed canonical-text
//! carrier ([`OracleNumber::Text`]) so correctness is never sacrificed. The
//! fallback is boxed (`Box<str>`) so the enum — and therefore
//! [`crate::thin::QueryValue`] — stays within its 32-byte budget.
//!
//! ## Single shared formatter
//!
//! [`OracleNumber::fmt_into`] is the ONE canonical formatter. It is BYTE-IDENTICAL
//! to the legacy [`super::codecs::decode_number_text_into`] text path (proven by
//! `tests/number_inline_byte_identical.rs` over the whole NUMBER domain). Every
//! consumer — `Display`, `FromSql<String>`, the OSON/JSON number text, and the
//! borrowed `QueryValueRef::Number` arena path — routes through it, so the owned
//! and borrowed decode paths can never diverge by even one byte.

use crate::Result;

/// Upper bound on the significant decimal digits the wire NUMBER digit walk can
/// emit into a stack buffer. Oracle NUMBER carries at most 40 significant
/// digits (20 base-100 mantissa bytes); +2 slack covers the `first_digit == 10`
/// base-100 carry the legacy walk can append.
pub(crate) const MAX_DIGITS: usize = 42;

/// Stack-decoded parts of a wire NUMBER (no heap allocation). Mirror of
/// [`DecodedNumber`] but with digits written into a caller stack buffer.
pub(crate) enum DecodedNumberStack {
    /// A single-byte sentinel whose canonical text is fixed.
    Sentinel {
        text: &'static str,
        is_integer: bool,
    },
    /// The decoded parts; `digit_len` significant digits were written to the
    /// caller's stack buffer.
    Parts {
        digit_len: usize,
        is_negative: bool,
        decimal_point_index: i16,
        is_integer: bool,
        /// The i128 coefficient FUSED during the digit walk (bead
        /// rust-oracledb-shh): `Some(coeff)` is byte-identical to a second
        /// `digits_to_i128(&digit_buf[..digit_len], is_negative)` pass; `None`
        /// signals i128 overflow (39–40 digit values), in which case the caller
        /// spills to boxed text using the still-filled `digit_buf` exactly as
        /// before. The sign is already applied.
        coefficient: Option<i128>,
    },
}

/// Inline, lossless decimal carrier for an Oracle `NUMBER`.
///
/// The common case is [`OracleNumber::Inline`] (`coefficient × 10^-scale`,
/// allocation-free). Values that cannot be represented exactly inline fall back
/// to [`OracleNumber::Text`] (a boxed canonical-text carrier).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OracleNumber {
    /// `value == coefficient × 10^-scale`, with the sign carried in
    /// `coefficient`. `scale` may be negative (the value has trailing zeros to
    /// the left of the implied point). `is_integer` mirrors the legacy decoder's
    /// flag — whether the canonical text contains a decimal point — so the
    /// Python int-vs-float dispatch is preserved exactly.
    ///
    /// The coefficient is stored as its little-endian `i128` bytes rather than a
    /// bare `i128` field: a bare `i128` forces 16-byte alignment, which rounds
    /// the enum up to 32 bytes and would blow `QueryValue`'s 32-byte budget once
    /// the discriminant is added. The `[u8; 16]` form keeps 8-byte alignment so
    /// `OracleNumber` is 24 bytes. Access via [`OracleNumber::coefficient`].
    Inline {
        coefficient_le: [u8; 16],
        scale: i16,
        is_integer: bool,
    },
    /// Defensive fallback for values that do not fit the inline form exactly
    /// (39–40 significant digit integers that overflow `i128`, or the `-1e126`
    /// single-byte sentinel). Boxed so the enum stays small.
    Text { text: Box<str>, is_integer: bool },
}

impl OracleNumber {
    /// Build the inline variant from a real `i128` coefficient (stored as its
    /// little-endian bytes to keep the enum 8-byte aligned).
    fn inline(coefficient: i128, scale: i16, is_integer: bool) -> Self {
        OracleNumber::Inline {
            coefficient_le: coefficient.to_le_bytes(),
            scale,
            is_integer,
        }
    }

    /// The inline coefficient as an `i128`, or `None` for the boxed-text
    /// fallback. `value == coefficient × 10^-scale`.
    pub fn coefficient(&self) -> Option<i128> {
        match self {
            OracleNumber::Inline { coefficient_le, .. } => {
                Some(i128::from_le_bytes(*coefficient_le))
            }
            OracleNumber::Text { .. } => None,
        }
    }

    /// The inline scale, or `None` for the boxed-text fallback.
    pub fn scale(&self) -> Option<i16> {
        match self {
            OracleNumber::Inline { scale, .. } => Some(*scale),
            OracleNumber::Text { .. } => None,
        }
    }

    /// Decode an Oracle `NUMBER` wire form into the inline representation,
    /// falling back to a boxed canonical-text carrier when the value cannot be
    /// represented exactly inline. The canonical text — whether produced inline
    /// or stored in the fallback — is byte-identical to the legacy decoder.
    ///
    /// ZERO-ALLOCATION for the common inline case: the digit walk writes into a
    /// fixed stack buffer (Oracle NUMBER has at most 40 significant digits), and
    /// the inline coefficient/scale is folded directly — no scratch `Vec`/`String`
    /// is heap-allocated. Only the rare text fallback (sentinel / i128 overflow)
    /// touches the heap, and only then.
    pub fn from_wire(bytes: &[u8]) -> Result<Self> {
        // Stack scratch: up to 40 significant decimal digits + slack for the
        // base-100 carry the digit walk can append.
        let mut digit_buf = [0u8; MAX_DIGITS];
        match super::codecs::decode_number_parts_stack(bytes, &mut digit_buf)? {
            // Single-byte sentinels: format their canonical text once.
            DecodedNumberStack::Sentinel { text, is_integer } => Ok(OracleNumber::Text {
                text: text.into(),
                is_integer,
            }),
            DecodedNumberStack::Parts {
                digit_len,
                is_negative,
                decimal_point_index,
                is_integer,
                coefficient,
            } => {
                let digits = &digit_buf[..digit_len];
                // The i128 coefficient was FUSED during the digit walk (bead
                // rust-oracledb-shh): `Some` is byte-identical to the old second
                // `digits_to_i128(digits, is_negative)` pass; `None` is the same
                // i128-overflow signal (39–40 digit value), which spills to text
                // using the still-filled `digits` exactly as before.
                match coefficient {
                    Some(coefficient) => {
                        // scale = len - decimal_point_index (implied fractional
                        // positions; may be negative for trailing-zero integers).
                        let len = i32::try_from(digits.len()).unwrap_or(i32::MAX);
                        let scale_i32 = len - i32::from(decimal_point_index);
                        match i16::try_from(scale_i32) {
                            Ok(scale) => Ok(OracleNumber::inline(coefficient, scale, is_integer)),
                            // Scale out of i16 range (cannot happen for valid
                            // Oracle NUMBER, but stay defensive): keep the text.
                            Err(_) => Ok(Self::spill_text(
                                digits,
                                is_negative,
                                decimal_point_index,
                                is_integer,
                            )),
                        }
                    }
                    // i128 overflow (39–40 digit value): spill to boxed text.
                    None => Ok(Self::spill_text(
                        digits,
                        is_negative,
                        decimal_point_index,
                        is_integer,
                    )),
                }
            }
        }
    }

    /// Format the digits into a boxed-text fallback (the rare path: i128 overflow
    /// or out-of-range scale). Uses the SAME formatter fragment as the inline
    /// path, so the text is byte-identical.
    fn spill_text(
        digits: &[u8],
        is_negative: bool,
        decimal_point_index: i16,
        is_integer: bool,
    ) -> Self {
        let mut text = String::new();
        super::codecs::format_number_digits(digits, is_negative, decimal_point_index, &mut text);
        OracleNumber::Text {
            text: text.into_boxed_str(),
            is_integer,
        }
    }

    /// Construct from already-canonical decimal text (the bind / parse path).
    /// Parses the text into the inline form when it fits, else keeps it boxed.
    /// The text MUST already be canonical Oracle `NUMBER` text (the form the
    /// decoder emits). Integral trailing-zero values are folded into the same
    /// coefficient/negative-scale form the wire decoder emits.
    pub fn from_canonical_text(text: &str) -> Self {
        Self::from_canonical_text_with_flag(text, !text.contains('.'))
    }

    /// Like [`Self::from_canonical_text`] but with the caller-supplied
    /// `is_integer` flag (the borrowed fetch path already decoded it from the
    /// wire, so it is authoritative — preserve it verbatim).
    pub fn from_canonical_text_with_flag(text: &str, is_integer: bool) -> Self {
        match parse_canonical_inline(text) {
            Some((coefficient, scale)) => OracleNumber::inline(coefficient, scale, is_integer),
            None => OracleNumber::Text {
                text: text.into(),
                is_integer,
            },
        }
    }

    /// Borrow the canonical text when it is stored as boxed text (the fallback
    /// form), else `None` — the inline numeric form synthesizes its text on
    /// demand and has no `&str` to lend.
    pub fn as_borrowed_text(&self) -> Option<&str> {
        match self {
            OracleNumber::Text { text, .. } => Some(text),
            OracleNumber::Inline { .. } => None,
        }
    }

    /// Whether the canonical text is integral (carries no decimal point).
    /// Mirrors the legacy `is_integer` flag exactly.
    pub fn is_integer(&self) -> bool {
        match self {
            OracleNumber::Inline { is_integer, .. } | OracleNumber::Text { is_integer, .. } => {
                *is_integer
            }
        }
    }

    /// THE single shared canonical formatter. Appends the canonical decimal text
    /// to `out`. Byte-identical to [`super::codecs::decode_number_text_into`].
    pub fn fmt_into(&self, out: &mut String) {
        match self {
            OracleNumber::Text { text, .. } => out.push_str(text),
            OracleNumber::Inline {
                coefficient_le,
                scale,
                ..
            } => fmt_inline_into(i128::from_le_bytes(*coefficient_le), *scale, out),
        }
    }

    /// Canonical decimal text as an owned `String`.
    pub fn to_canonical_string(&self) -> String {
        let mut out = String::new();
        self.fmt_into(&mut out);
        out
    }

    /// Canonical decimal text as a `Cow`: borrowed for the boxed-text fallback
    /// (zero allocation), owned for the inline form (formatted once on demand).
    pub fn to_canonical_cow(&self) -> std::borrow::Cow<'_, str> {
        match self {
            OracleNumber::Text { text, .. } => std::borrow::Cow::Borrowed(text),
            OracleNumber::Inline { .. } => std::borrow::Cow::Owned(self.to_canonical_string()),
        }
    }

    /// Exact `i64` when the value is an integer that fits; else `None`.
    pub fn to_i64(&self) -> Option<i64> {
        match self {
            OracleNumber::Inline {
                coefficient_le,
                scale,
                ..
            } => inline_to_i128(i128::from_le_bytes(*coefficient_le), *scale)
                .and_then(|v| i64::try_from(v).ok()),
            OracleNumber::Text { text, .. } => text.parse::<i64>().ok(),
        }
    }

    /// Exact `i128` when the value is an integer that fits; else `None`.
    pub fn to_i128(&self) -> Option<i128> {
        match self {
            OracleNumber::Inline {
                coefficient_le,
                scale,
                ..
            } => inline_to_i128(i128::from_le_bytes(*coefficient_le), *scale),
            OracleNumber::Text { text, .. } => text.parse::<i128>().ok(),
        }
    }
}

/// Outcome of the wire digit walk: either a sentinel/overflow case that must be
/// kept as text, or the decoded parts the inline form is built from.
pub(crate) enum DecodedNumber {
    /// The canonical text is already in `text`; keep it verbatim (the special
    /// single-byte sentinel cases that are not plain `coeff × 10^-scale`).
    Text { is_integer: bool },
    /// Parts to fold into the inline coefficient/scale form.
    Parts {
        is_negative: bool,
        decimal_point_index: i16,
        is_integer: bool,
    },
}

/// Fold the significant decimal `digits` (each 0..=9) into an `i128` coefficient
/// with the given sign, returning `None` on overflow (39–40 digit values that
/// exceed `i128`).
///
/// This is the reference the FUSED in-walk accumulator (bead rust-oracledb-shh,
/// `decode_number_parts_stack`) must reproduce byte-for-byte. It is retained as
/// the differential oracle for that fusion (see the `fused_coefficient_matches_
/// reference_walk` test) and is otherwise unused in production code.
#[cfg(test)]
fn digits_to_i128(digits: &[u8], is_negative: bool) -> Option<i128> {
    let mut acc: i128 = 0;
    for &d in digits {
        acc = acc.checked_mul(10)?.checked_add(i128::from(d))?;
    }
    if is_negative {
        Some(-acc)
    } else {
        Some(acc)
    }
}

/// Reconstruct an exact integer `i128` from the inline form, or `None` if the
/// value is fractional or the scaling overflows.
fn inline_to_i128(coefficient: i128, scale: i16) -> Option<i128> {
    match scale.cmp(&0) {
        std::cmp::Ordering::Equal => Some(coefficient),
        // Negative scale: value = coefficient × 10^(-scale), an integer.
        std::cmp::Ordering::Less => {
            let mut v = coefficient;
            for _ in 0..(-(i32::from(scale))) {
                v = v.checked_mul(10)?;
            }
            Some(v)
        }
        // Positive scale: integral only if the trailing `scale` digits are zero.
        std::cmp::Ordering::Greater => {
            let mut divisor: i128 = 1;
            for _ in 0..i32::from(scale) {
                divisor = divisor.checked_mul(10)?;
            }
            if coefficient % divisor == 0 {
                Some(coefficient / divisor)
            } else {
                None
            }
        }
    }
}

/// Format the inline `coefficient × 10^-scale` form into canonical Oracle
/// `NUMBER` text, BYTE-IDENTICAL to the legacy `decode_number_text_into`.
///
/// The legacy formatter works from `digits` (significant decimal digits, no
/// leading/trailing zeros except as positioned) and `decimal_point_index`. Here
/// the equivalent inputs are recovered as: the absolute coefficient's decimal
/// digits, and `decimal_point_index = digit_count - scale`.
fn fmt_inline_into(coefficient: i128, scale: i16, out: &mut String) {
    // Zero is always rendered "0" (matches the legacy single-byte-zero path and
    // the negative-zero canonicalization).
    if coefficient == 0 {
        out.push('0');
        return;
    }

    let is_negative = coefficient < 0;
    // Build the significant-digit string of |coefficient|. unsigned_abs avoids
    // the i128::MIN overflow trap.
    let mut buf = [0u8; 40];
    let mut mag = coefficient.unsigned_abs();
    let mut idx = buf.len();
    while mag > 0 {
        idx -= 1;
        buf[idx] = b'0' + (mag % 10) as u8;
        mag /= 10;
    }
    let digits = &buf[idx..];
    let digit_count = digits.len() as i32;
    let decimal_point_index = digit_count - i32::from(scale);

    if is_negative {
        out.push('-');
    }

    if decimal_point_index <= 0 {
        // "0." + (-decimal_point_index) zeros + all digits.
        out.push_str("0.");
        for _ in decimal_point_index..0 {
            out.push('0');
        }
        for &d in digits {
            out.push(d as char);
        }
        return;
    }

    // decimal_point_index > 0: emit digits, inserting '.' at the point, and pad
    // trailing zeros when the point is past the last digit.
    for (i, &d) in digits.iter().enumerate() {
        if i as i32 == decimal_point_index {
            out.push('.');
        }
        out.push(d as char);
    }
    if decimal_point_index > digit_count {
        for _ in digit_count..decimal_point_index {
            out.push('0');
        }
    }
}

/// Parse already-canonical Oracle `NUMBER` text into `(coefficient, scale)`,
/// returning `None` if it does not fit `i128`/`i16` (then the caller keeps the
/// text). The input is the decoder's canonical form: an optional `-`, digits,
/// an optional single `.`, no exponent (except the `-1e126` sentinel, which has
/// an `e` and is therefore rejected here -> text fallback).
///
/// For integral values, trim trailing decimal zeros into a negative scale so
/// text materialization of a borrowed NUMBER matches the owned wire decoder's
/// inline representation for values like `1000`.
fn parse_canonical_inline(text: &str) -> Option<(i128, i16)> {
    let (is_negative, rest) = match text.strip_prefix('-') {
        Some(r) => (true, r),
        None => (false, text),
    };
    if rest.is_empty() {
        return None;
    }
    let (int_part, frac_part) = match rest.split_once('.') {
        Some((i, f)) => (i, f),
        None => (rest, ""),
    };
    // Canonical text never contains an exponent or any non-digit beyond one '.'.
    if !int_part.bytes().all(|b| b.is_ascii_digit())
        || !frac_part.bytes().all(|b| b.is_ascii_digit())
    {
        return None;
    }
    let mut acc: i128 = 0;
    for b in int_part.bytes().chain(frac_part.bytes()) {
        acc = acc.checked_mul(10)?.checked_add(i128::from(b - b'0'))?;
    }
    let mut coefficient = if is_negative { acc.checked_neg()? } else { acc };
    let mut scale = i16::try_from(frac_part.len()).ok()?;
    if coefficient == 0 && scale == 0 {
        return None;
    }
    if scale == 0 {
        while coefficient % 10 == 0 {
            coefficient /= 10;
            scale = scale.checked_sub(1)?;
        }
    }
    Some((coefficient, scale))
}

impl std::fmt::Display for OracleNumber {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let mut s = String::new();
        self.fmt_into(&mut s);
        f.write_str(&s)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::thin::codecs::{decode_number_parts_stack, encode_number_text};

    /// Differential proof for the fused i128 accumulator (bead rust-oracledb-shh):
    /// the `coefficient` fused during `decode_number_parts_stack`'s digit walk
    /// MUST equal the reference second pass `digits_to_i128(digits, is_negative)`
    /// over the still-filled digit buffer — including the overflow (`None`)
    /// boundary. If these ever diverge, the inline NUMBER coefficient (and thus
    /// the canonical text, the i64/i128 reconstruct, the whole parity surface)
    /// would silently drift, so this is the gate for the optimization.
    fn assert_fused_matches_reference(wire: &[u8], label: &str) {
        let mut digit_buf = [0u8; MAX_DIGITS];
        let parts = decode_number_parts_stack(wire, &mut digit_buf).expect("decode valid wire");
        if let DecodedNumberStack::Parts {
            digit_len,
            is_negative,
            coefficient,
            ..
        } = parts
        {
            let reference = digits_to_i128(&digit_buf[..digit_len], is_negative);
            assert_eq!(
                coefficient, reference,
                "{label}: fused coefficient {coefficient:?} != reference walk {reference:?} \
                 (wire={wire:02x?})"
            );
        }
    }

    #[test]
    fn fused_coefficient_matches_reference_walk_corpus() {
        // Spans the inline domain plus the i128-overflow boundary (39–40 digits).
        let corpus: &[&str] = &[
            "0",
            "1",
            "-1",
            "9",
            "-9",
            "10",
            "99",
            "-99",
            "100",
            "12345",
            "-12345",
            "0.5",
            "-0.5",
            "3.14159",
            "100.001",
            "0.0001",
            "1000000000000000000",
            "12345678901234567890",
            "123456789012345678901234567890",
            // 38 significant digits (max inline precision).
            "12345678901234567890123456789012345678",
            "-12345678901234567890123456789012345678",
            "0.12345678901234567890123456789012345678",
            // 39+ digits: i128 overflow -> fused must latch None, same as ref.
            "123456789012345678901234567890123456789",
            "9999999999999999999999999999999999999999", // 40 nines
            "1e125",
            "-1e125",
            "1e-120",
        ];
        for text in corpus {
            let wire = encode_number_text(text).unwrap_or_else(|e| panic!("encode {text}: {e:?}"));
            assert_fused_matches_reference(&wire, text);
        }
    }

    #[test]
    fn inline_form_fits_the_size_budget() {
        // The inline carrier must stay <= 24 bytes (8-byte aligned via the
        // [u8;16] coefficient) so `QueryValue` holds its 32-byte budget.
        assert!(core::mem::size_of::<OracleNumber>() <= 24);
        assert_eq!(core::mem::align_of::<OracleNumber>(), 8);
    }

    #[test]
    fn formatter_matches_known_canonical_text() {
        // coefficient × 10^-scale -> canonical text, spot checks.
        let cases: &[(i128, i16, bool, &str)] = &[
            (0, 0, true, "0"),
            (1, 0, true, "1"),
            (-1, 0, true, "-1"),
            (5, 1, false, "0.5"),
            (-5, 1, false, "-0.5"),
            (314159, 5, false, "3.14159"),
            (1, -2, true, "100"), // 1 × 10^2
            (12, 0, true, "12"),
            (100001, 3, false, "100.001"),
            (15, 1, false, "1.5"),
        ];
        for &(coeff, scale, is_int, expect) in cases {
            let n = OracleNumber::inline(coeff, scale, is_int);
            assert_eq!(
                n.to_canonical_string(),
                expect,
                "coeff={coeff} scale={scale}"
            );
            assert_eq!(n.is_integer(), is_int);
        }
    }

    #[test]
    fn from_canonical_text_round_trips() {
        for text in [
            "0",
            "1",
            "-1",
            "0.5",
            "100",
            "0.001",
            "12345678901234567890",
        ] {
            let n = OracleNumber::from_canonical_text(text);
            assert_eq!(n.to_canonical_string(), text);
        }
    }

    #[test]
    fn from_canonical_text_matches_wire_decoder_for_trailing_zero_integers() {
        for text in ["10", "100", "-1000", "1000000000000000000"] {
            let wire = encode_number_text(text).expect("encode trailing-zero integer");
            let from_wire = OracleNumber::from_wire(&wire).expect("decode trailing-zero integer");
            let from_text = OracleNumber::from_canonical_text(text);
            assert_eq!(
                from_text, from_wire,
                "canonical text materialization should match wire decode for {text}"
            );
            assert_eq!(from_text.to_canonical_string(), text);
        }
    }

    #[test]
    fn owned_and_borrowed_number_decode_agree_for_large_integers() {
        for text in [
            "1",
            "100",
            "1000000000000000000",                       // 19 digits
            "99999999999999999999999999999999999999",    // 38 nines (inline max)
            "100000000000000000000000000000000000000",   // 1e38 (39 digits)
            "1000000000000000000000000000000000000000",  // 1e39 (40 digits)
            "10000000000000000000000000000000000000000", // 1e40
        ] {
            let wire = match encode_number_text(text) {
                Ok(wire) => wire,
                Err(err) => panic!("encode {text}: {err:?}"),
            };
            // Owned path:
            let owned = OracleNumber::from_wire(&wire).expect("from_wire");
            // Borrowed path: decode wire to canonical text, then to_owned_value's
            // from_canonical_text_with_flag.
            let mut digits = Vec::new();
            let mut canon = String::new();
            let is_int =
                crate::thin::codecs::decode_number_text_into(&wire, &mut digits, &mut canon)
                    .expect("decode_number_text_into");
            let borrowed = OracleNumber::from_canonical_text_with_flag(&canon, is_int);
            // Observable values must always match even when the enum variant diverges.
            assert_eq!(
                owned.to_canonical_string(),
                borrowed.to_canonical_string(),
                "canon {text}"
            );
            assert_eq!(owned.to_i128(), borrowed.to_i128(), "i128 {text}");
            assert_eq!(owned.to_i64(), borrowed.to_i64(), "i64 {text}");
            assert_eq!(owned.is_integer(), borrowed.is_integer(), "is_int {text}");
        }
    }

    #[test]
    fn overflow_value_falls_back_to_text_losslessly() {
        // A 40-digit integer exceeds i128 (39 digits max); the canonical text
        // round-trips through the boxed fallback exactly. Built via from_wire of
        // a synthetic value would require the encoder, so assert the fallback
        // constructor preserves the text verbatim.
        let big = "1234567890123456789012345678901234567890"; // 40 digits
        let n = OracleNumber::from_canonical_text(big);
        assert!(
            matches!(n, OracleNumber::Text { .. }),
            "40-digit -> text fallback"
        );
        assert_eq!(n.to_canonical_string(), big);
    }
}