oracledb_protocol/thin/number.rs
1#![forbid(unsafe_code)]
2
3//! Inline, lossless Oracle `NUMBER` representation (bead rust-oracledb-65w).
4//!
5//! Oracle `NUMBER` is up to 40 significant decimal digits (the wire form carries
6//! up to 20 base-100 mantissa bytes) with a decimal exponent in roughly
7//! `-130..=125`. The common case — a value with at most 38 significant digits —
8//! fits losslessly in an `i128` coefficient plus an `i16` scale, allocating
9//! nothing. The owned [`crate::thin::QueryValue::Number`] used to carry a heap
10//! `String` per cell; this module replaces that inline payload so a NUMBER-heavy
11//! row stops doing one `malloc` per NUMBER column.
12//!
13//! ## Losslessness
14//!
15//! Some wire forms cannot be represented exactly inline:
16//!
17//! - A 39- or 40-digit integer can exceed `i128::MAX` (`~1.7e38`, 39 digits).
18//! - The decoder's special single-byte negative sentinel renders as the literal
19//! text `-1e126`, which is not a plain `coefficient × 10^-scale` decimal.
20//!
21//! For any such value the representation FALLS BACK to a boxed canonical-text
22//! carrier ([`OracleNumber::Text`]) so correctness is never sacrificed. The
23//! fallback is boxed (`Box<str>`) so the enum — and therefore
24//! [`crate::thin::QueryValue`] — stays within its 32-byte budget.
25//!
26//! ## Single shared formatter
27//!
28//! [`OracleNumber::fmt_into`] is the ONE canonical formatter. It is BYTE-IDENTICAL
29//! to the legacy [`super::codecs::decode_number_text_into`] text path (proven by
30//! `tests/number_inline_byte_identical.rs` over the whole NUMBER domain). Every
31//! consumer — `Display`, `FromSql<String>`, the OSON/JSON number text, and the
32//! borrowed `QueryValueRef::Number` arena path — routes through it, so the owned
33//! and borrowed decode paths can never diverge by even one byte.
34
35use crate::Result;
36
37/// Upper bound on the significant decimal digits the wire NUMBER digit walk can
38/// emit into a stack buffer. Oracle NUMBER carries at most 40 significant
39/// digits (20 base-100 mantissa bytes); +2 slack covers the `first_digit == 10`
40/// base-100 carry the legacy walk can append.
41pub(crate) const MAX_DIGITS: usize = 42;
42
43/// Stack-decoded parts of a wire NUMBER (no heap allocation). Mirror of
44/// [`DecodedNumber`] but with digits written into a caller stack buffer.
45pub(crate) enum DecodedNumberStack {
46 /// A single-byte sentinel whose canonical text is fixed.
47 Sentinel {
48 text: &'static str,
49 is_integer: bool,
50 },
51 /// The decoded parts; `digit_len` significant digits were written to the
52 /// caller's stack buffer.
53 Parts {
54 digit_len: usize,
55 is_negative: bool,
56 decimal_point_index: i16,
57 is_integer: bool,
58 /// The i128 coefficient FUSED during the digit walk (bead
59 /// rust-oracledb-shh): `Some(coeff)` is byte-identical to a second
60 /// `digits_to_i128(&digit_buf[..digit_len], is_negative)` pass; `None`
61 /// signals i128 overflow (39–40 digit values), in which case the caller
62 /// spills to boxed text using the still-filled `digit_buf` exactly as
63 /// before. The sign is already applied.
64 coefficient: Option<i128>,
65 },
66}
67
68/// Inline, lossless decimal carrier for an Oracle `NUMBER`.
69///
70/// The common case is [`OracleNumber::Inline`] (`coefficient × 10^-scale`,
71/// allocation-free). Values that cannot be represented exactly inline fall back
72/// to [`OracleNumber::Text`] (a boxed canonical-text carrier).
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub enum OracleNumber {
75 /// `value == coefficient × 10^-scale`, with the sign carried in
76 /// `coefficient`. `scale` may be negative (the value has trailing zeros to
77 /// the left of the implied point). `is_integer` mirrors the legacy decoder's
78 /// flag — whether the canonical text contains a decimal point — so the
79 /// Python int-vs-float dispatch is preserved exactly.
80 ///
81 /// The coefficient is stored as its little-endian `i128` bytes rather than a
82 /// bare `i128` field: a bare `i128` forces 16-byte alignment, which rounds
83 /// the enum up to 32 bytes and would blow `QueryValue`'s 32-byte budget once
84 /// the discriminant is added. The `[u8; 16]` form keeps 8-byte alignment so
85 /// `OracleNumber` is 24 bytes. Access via [`OracleNumber::coefficient`].
86 Inline {
87 coefficient_le: [u8; 16],
88 scale: i16,
89 is_integer: bool,
90 },
91 /// Defensive fallback for values that do not fit the inline form exactly
92 /// (39–40 significant digit integers that overflow `i128`, or the `-1e126`
93 /// single-byte sentinel). Boxed so the enum stays small.
94 Text { text: Box<str>, is_integer: bool },
95}
96
97impl OracleNumber {
98 /// Build the inline variant from a real `i128` coefficient (stored as its
99 /// little-endian bytes to keep the enum 8-byte aligned).
100 fn inline(coefficient: i128, scale: i16, is_integer: bool) -> Self {
101 OracleNumber::Inline {
102 coefficient_le: coefficient.to_le_bytes(),
103 scale,
104 is_integer,
105 }
106 }
107
108 /// The inline coefficient as an `i128`, or `None` for the boxed-text
109 /// fallback. `value == coefficient × 10^-scale`.
110 pub fn coefficient(&self) -> Option<i128> {
111 match self {
112 OracleNumber::Inline { coefficient_le, .. } => {
113 Some(i128::from_le_bytes(*coefficient_le))
114 }
115 OracleNumber::Text { .. } => None,
116 }
117 }
118
119 /// The inline scale, or `None` for the boxed-text fallback.
120 pub fn scale(&self) -> Option<i16> {
121 match self {
122 OracleNumber::Inline { scale, .. } => Some(*scale),
123 OracleNumber::Text { .. } => None,
124 }
125 }
126
127 /// Decode an Oracle `NUMBER` wire form into the inline representation,
128 /// falling back to a boxed canonical-text carrier when the value cannot be
129 /// represented exactly inline. The canonical text — whether produced inline
130 /// or stored in the fallback — is byte-identical to the legacy decoder.
131 ///
132 /// ZERO-ALLOCATION for the common inline case: the digit walk writes into a
133 /// fixed stack buffer (Oracle NUMBER has at most 40 significant digits), and
134 /// the inline coefficient/scale is folded directly — no scratch `Vec`/`String`
135 /// is heap-allocated. Only the rare text fallback (sentinel / i128 overflow)
136 /// touches the heap, and only then.
137 pub fn from_wire(bytes: &[u8]) -> Result<Self> {
138 // Stack scratch: up to 40 significant decimal digits + slack for the
139 // base-100 carry the digit walk can append.
140 let mut digit_buf = [0u8; MAX_DIGITS];
141 match super::codecs::decode_number_parts_stack(bytes, &mut digit_buf)? {
142 // Single-byte sentinels: format their canonical text once.
143 DecodedNumberStack::Sentinel { text, is_integer } => Ok(OracleNumber::Text {
144 text: text.into(),
145 is_integer,
146 }),
147 DecodedNumberStack::Parts {
148 digit_len,
149 is_negative,
150 decimal_point_index,
151 is_integer,
152 coefficient,
153 } => {
154 let digits = &digit_buf[..digit_len];
155 // The i128 coefficient was FUSED during the digit walk (bead
156 // rust-oracledb-shh): `Some` is byte-identical to the old second
157 // `digits_to_i128(digits, is_negative)` pass; `None` is the same
158 // i128-overflow signal (39–40 digit value), which spills to text
159 // using the still-filled `digits` exactly as before.
160 match coefficient {
161 Some(coefficient) => {
162 // scale = len - decimal_point_index (implied fractional
163 // positions; may be negative for trailing-zero integers).
164 let len = i32::try_from(digits.len()).unwrap_or(i32::MAX);
165 let scale_i32 = len - i32::from(decimal_point_index);
166 match i16::try_from(scale_i32) {
167 Ok(scale) => Ok(OracleNumber::inline(coefficient, scale, is_integer)),
168 // Scale out of i16 range (cannot happen for valid
169 // Oracle NUMBER, but stay defensive): keep the text.
170 Err(_) => Ok(Self::spill_text(
171 digits,
172 is_negative,
173 decimal_point_index,
174 is_integer,
175 )),
176 }
177 }
178 // i128 overflow (39–40 digit value): spill to boxed text.
179 None => Ok(Self::spill_text(
180 digits,
181 is_negative,
182 decimal_point_index,
183 is_integer,
184 )),
185 }
186 }
187 }
188 }
189
190 /// Format the digits into a boxed-text fallback (the rare path: i128 overflow
191 /// or out-of-range scale). Uses the SAME formatter fragment as the inline
192 /// path, so the text is byte-identical.
193 fn spill_text(
194 digits: &[u8],
195 is_negative: bool,
196 decimal_point_index: i16,
197 is_integer: bool,
198 ) -> Self {
199 let mut text = String::new();
200 super::codecs::format_number_digits(digits, is_negative, decimal_point_index, &mut text);
201 OracleNumber::Text {
202 text: text.into_boxed_str(),
203 is_integer,
204 }
205 }
206
207 /// Construct from already-canonical decimal text (the bind / parse path).
208 /// Parses the text into the inline form when it fits, else keeps it boxed.
209 /// The text MUST already be canonical Oracle `NUMBER` text (the form the
210 /// decoder emits). Integral trailing-zero values are folded into the same
211 /// coefficient/negative-scale form the wire decoder emits.
212 pub fn from_canonical_text(text: &str) -> Self {
213 Self::from_canonical_text_with_flag(text, !text.contains('.'))
214 }
215
216 /// Like [`Self::from_canonical_text`] but with the caller-supplied
217 /// `is_integer` flag (the borrowed fetch path already decoded it from the
218 /// wire, so it is authoritative — preserve it verbatim).
219 pub fn from_canonical_text_with_flag(text: &str, is_integer: bool) -> Self {
220 match parse_canonical_inline(text) {
221 Some((coefficient, scale)) => OracleNumber::inline(coefficient, scale, is_integer),
222 None => OracleNumber::Text {
223 text: text.into(),
224 is_integer,
225 },
226 }
227 }
228
229 /// Borrow the canonical text when it is stored as boxed text (the fallback
230 /// form), else `None` — the inline numeric form synthesizes its text on
231 /// demand and has no `&str` to lend.
232 pub fn as_borrowed_text(&self) -> Option<&str> {
233 match self {
234 OracleNumber::Text { text, .. } => Some(text),
235 OracleNumber::Inline { .. } => None,
236 }
237 }
238
239 /// Whether the canonical text is integral (carries no decimal point).
240 /// Mirrors the legacy `is_integer` flag exactly.
241 pub fn is_integer(&self) -> bool {
242 match self {
243 OracleNumber::Inline { is_integer, .. } | OracleNumber::Text { is_integer, .. } => {
244 *is_integer
245 }
246 }
247 }
248
249 /// THE single shared canonical formatter. Appends the canonical decimal text
250 /// to `out`. Byte-identical to [`super::codecs::decode_number_text_into`].
251 pub fn fmt_into(&self, out: &mut String) {
252 match self {
253 OracleNumber::Text { text, .. } => out.push_str(text),
254 OracleNumber::Inline {
255 coefficient_le,
256 scale,
257 ..
258 } => fmt_inline_into(i128::from_le_bytes(*coefficient_le), *scale, out),
259 }
260 }
261
262 /// Canonical decimal text as an owned `String`.
263 pub fn to_canonical_string(&self) -> String {
264 let mut out = String::new();
265 self.fmt_into(&mut out);
266 out
267 }
268
269 /// Canonical decimal text as a `Cow`: borrowed for the boxed-text fallback
270 /// (zero allocation), owned for the inline form (formatted once on demand).
271 pub fn to_canonical_cow(&self) -> std::borrow::Cow<'_, str> {
272 match self {
273 OracleNumber::Text { text, .. } => std::borrow::Cow::Borrowed(text),
274 OracleNumber::Inline { .. } => std::borrow::Cow::Owned(self.to_canonical_string()),
275 }
276 }
277
278 /// Exact `i64` when the value is an integer that fits; else `None`.
279 pub fn to_i64(&self) -> Option<i64> {
280 match self {
281 OracleNumber::Inline {
282 coefficient_le,
283 scale,
284 ..
285 } => inline_to_i128(i128::from_le_bytes(*coefficient_le), *scale)
286 .and_then(|v| i64::try_from(v).ok()),
287 OracleNumber::Text { text, .. } => text.parse::<i64>().ok(),
288 }
289 }
290
291 /// Exact `i128` when the value is an integer that fits; else `None`.
292 pub fn to_i128(&self) -> Option<i128> {
293 match self {
294 OracleNumber::Inline {
295 coefficient_le,
296 scale,
297 ..
298 } => inline_to_i128(i128::from_le_bytes(*coefficient_le), *scale),
299 OracleNumber::Text { text, .. } => text.parse::<i128>().ok(),
300 }
301 }
302}
303
304/// Outcome of the wire digit walk: either a sentinel/overflow case that must be
305/// kept as text, or the decoded parts the inline form is built from.
306pub(crate) enum DecodedNumber {
307 /// The canonical text is already in `text`; keep it verbatim (the special
308 /// single-byte sentinel cases that are not plain `coeff × 10^-scale`).
309 Text { is_integer: bool },
310 /// Parts to fold into the inline coefficient/scale form.
311 Parts {
312 is_negative: bool,
313 decimal_point_index: i16,
314 is_integer: bool,
315 },
316}
317
318/// Fold the significant decimal `digits` (each 0..=9) into an `i128` coefficient
319/// with the given sign, returning `None` on overflow (39–40 digit values that
320/// exceed `i128`).
321///
322/// This is the reference the FUSED in-walk accumulator (bead rust-oracledb-shh,
323/// `decode_number_parts_stack`) must reproduce byte-for-byte. It is retained as
324/// the differential oracle for that fusion (see the `fused_coefficient_matches_
325/// reference_walk` test) and is otherwise unused in production code.
326#[cfg(test)]
327fn digits_to_i128(digits: &[u8], is_negative: bool) -> Option<i128> {
328 let mut acc: i128 = 0;
329 for &d in digits {
330 acc = acc.checked_mul(10)?.checked_add(i128::from(d))?;
331 }
332 if is_negative {
333 Some(-acc)
334 } else {
335 Some(acc)
336 }
337}
338
339/// Reconstruct an exact integer `i128` from the inline form, or `None` if the
340/// value is fractional or the scaling overflows.
341fn inline_to_i128(coefficient: i128, scale: i16) -> Option<i128> {
342 match scale.cmp(&0) {
343 std::cmp::Ordering::Equal => Some(coefficient),
344 // Negative scale: value = coefficient × 10^(-scale), an integer.
345 std::cmp::Ordering::Less => {
346 let mut v = coefficient;
347 for _ in 0..(-(i32::from(scale))) {
348 v = v.checked_mul(10)?;
349 }
350 Some(v)
351 }
352 // Positive scale: integral only if the trailing `scale` digits are zero.
353 std::cmp::Ordering::Greater => {
354 let mut divisor: i128 = 1;
355 for _ in 0..i32::from(scale) {
356 divisor = divisor.checked_mul(10)?;
357 }
358 if coefficient % divisor == 0 {
359 Some(coefficient / divisor)
360 } else {
361 None
362 }
363 }
364 }
365}
366
367/// Format the inline `coefficient × 10^-scale` form into canonical Oracle
368/// `NUMBER` text, BYTE-IDENTICAL to the legacy `decode_number_text_into`.
369///
370/// The legacy formatter works from `digits` (significant decimal digits, no
371/// leading/trailing zeros except as positioned) and `decimal_point_index`. Here
372/// the equivalent inputs are recovered as: the absolute coefficient's decimal
373/// digits, and `decimal_point_index = digit_count - scale`.
374fn fmt_inline_into(coefficient: i128, scale: i16, out: &mut String) {
375 // Zero is always rendered "0" (matches the legacy single-byte-zero path and
376 // the negative-zero canonicalization).
377 if coefficient == 0 {
378 out.push('0');
379 return;
380 }
381
382 let is_negative = coefficient < 0;
383 // Build the significant-digit string of |coefficient|. unsigned_abs avoids
384 // the i128::MIN overflow trap.
385 let mut buf = [0u8; 40];
386 let mut mag = coefficient.unsigned_abs();
387 let mut idx = buf.len();
388 while mag > 0 {
389 idx -= 1;
390 buf[idx] = b'0' + (mag % 10) as u8;
391 mag /= 10;
392 }
393 let digits = &buf[idx..];
394 let digit_count = digits.len() as i32;
395 let decimal_point_index = digit_count - i32::from(scale);
396
397 if is_negative {
398 out.push('-');
399 }
400
401 if decimal_point_index <= 0 {
402 // "0." + (-decimal_point_index) zeros + all digits.
403 out.push_str("0.");
404 for _ in decimal_point_index..0 {
405 out.push('0');
406 }
407 for &d in digits {
408 out.push(d as char);
409 }
410 return;
411 }
412
413 // decimal_point_index > 0: emit digits, inserting '.' at the point, and pad
414 // trailing zeros when the point is past the last digit.
415 for (i, &d) in digits.iter().enumerate() {
416 if i as i32 == decimal_point_index {
417 out.push('.');
418 }
419 out.push(d as char);
420 }
421 if decimal_point_index > digit_count {
422 for _ in digit_count..decimal_point_index {
423 out.push('0');
424 }
425 }
426}
427
428/// Parse already-canonical Oracle `NUMBER` text into `(coefficient, scale)`,
429/// returning `None` if it does not fit `i128`/`i16` (then the caller keeps the
430/// text). The input is the decoder's canonical form: an optional `-`, digits,
431/// an optional single `.`, no exponent (except the `-1e126` sentinel, which has
432/// an `e` and is therefore rejected here -> text fallback).
433///
434/// For integral values, trim trailing decimal zeros into a negative scale so
435/// text materialization of a borrowed NUMBER matches the owned wire decoder's
436/// inline representation for values like `1000`.
437fn parse_canonical_inline(text: &str) -> Option<(i128, i16)> {
438 let (is_negative, rest) = match text.strip_prefix('-') {
439 Some(r) => (true, r),
440 None => (false, text),
441 };
442 if rest.is_empty() {
443 return None;
444 }
445 let (int_part, frac_part) = match rest.split_once('.') {
446 Some((i, f)) => (i, f),
447 None => (rest, ""),
448 };
449 // Canonical text never contains an exponent or any non-digit beyond one '.'.
450 if !int_part.bytes().all(|b| b.is_ascii_digit())
451 || !frac_part.bytes().all(|b| b.is_ascii_digit())
452 {
453 return None;
454 }
455 let mut acc: i128 = 0;
456 for b in int_part.bytes().chain(frac_part.bytes()) {
457 acc = acc.checked_mul(10)?.checked_add(i128::from(b - b'0'))?;
458 }
459 let mut coefficient = if is_negative { acc.checked_neg()? } else { acc };
460 let mut scale = i16::try_from(frac_part.len()).ok()?;
461 if coefficient == 0 && scale == 0 {
462 return None;
463 }
464 if scale == 0 {
465 while coefficient % 10 == 0 {
466 coefficient /= 10;
467 scale = scale.checked_sub(1)?;
468 }
469 }
470 Some((coefficient, scale))
471}
472
473impl std::fmt::Display for OracleNumber {
474 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475 let mut s = String::new();
476 self.fmt_into(&mut s);
477 f.write_str(&s)
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::thin::codecs::{decode_number_parts_stack, encode_number_text};
485
486 /// Differential proof for the fused i128 accumulator (bead rust-oracledb-shh):
487 /// the `coefficient` fused during `decode_number_parts_stack`'s digit walk
488 /// MUST equal the reference second pass `digits_to_i128(digits, is_negative)`
489 /// over the still-filled digit buffer — including the overflow (`None`)
490 /// boundary. If these ever diverge, the inline NUMBER coefficient (and thus
491 /// the canonical text, the i64/i128 reconstruct, the whole parity surface)
492 /// would silently drift, so this is the gate for the optimization.
493 fn assert_fused_matches_reference(wire: &[u8], label: &str) {
494 let mut digit_buf = [0u8; MAX_DIGITS];
495 let parts = decode_number_parts_stack(wire, &mut digit_buf).expect("decode valid wire");
496 if let DecodedNumberStack::Parts {
497 digit_len,
498 is_negative,
499 coefficient,
500 ..
501 } = parts
502 {
503 let reference = digits_to_i128(&digit_buf[..digit_len], is_negative);
504 assert_eq!(
505 coefficient, reference,
506 "{label}: fused coefficient {coefficient:?} != reference walk {reference:?} \
507 (wire={wire:02x?})"
508 );
509 }
510 }
511
512 #[test]
513 fn fused_coefficient_matches_reference_walk_corpus() {
514 // Spans the inline domain plus the i128-overflow boundary (39–40 digits).
515 let corpus: &[&str] = &[
516 "0",
517 "1",
518 "-1",
519 "9",
520 "-9",
521 "10",
522 "99",
523 "-99",
524 "100",
525 "12345",
526 "-12345",
527 "0.5",
528 "-0.5",
529 "3.14159",
530 "100.001",
531 "0.0001",
532 "1000000000000000000",
533 "12345678901234567890",
534 "123456789012345678901234567890",
535 // 38 significant digits (max inline precision).
536 "12345678901234567890123456789012345678",
537 "-12345678901234567890123456789012345678",
538 "0.12345678901234567890123456789012345678",
539 // 39+ digits: i128 overflow -> fused must latch None, same as ref.
540 "123456789012345678901234567890123456789",
541 "9999999999999999999999999999999999999999", // 40 nines
542 "1e125",
543 "-1e125",
544 "1e-120",
545 ];
546 for text in corpus {
547 let wire = encode_number_text(text).unwrap_or_else(|e| panic!("encode {text}: {e:?}"));
548 assert_fused_matches_reference(&wire, text);
549 }
550 }
551
552 #[test]
553 fn inline_form_fits_the_size_budget() {
554 // The inline carrier must stay <= 24 bytes (8-byte aligned via the
555 // [u8;16] coefficient) so `QueryValue` holds its 32-byte budget.
556 assert!(core::mem::size_of::<OracleNumber>() <= 24);
557 assert_eq!(core::mem::align_of::<OracleNumber>(), 8);
558 }
559
560 #[test]
561 fn formatter_matches_known_canonical_text() {
562 // coefficient × 10^-scale -> canonical text, spot checks.
563 let cases: &[(i128, i16, bool, &str)] = &[
564 (0, 0, true, "0"),
565 (1, 0, true, "1"),
566 (-1, 0, true, "-1"),
567 (5, 1, false, "0.5"),
568 (-5, 1, false, "-0.5"),
569 (314159, 5, false, "3.14159"),
570 (1, -2, true, "100"), // 1 × 10^2
571 (12, 0, true, "12"),
572 (100001, 3, false, "100.001"),
573 (15, 1, false, "1.5"),
574 ];
575 for &(coeff, scale, is_int, expect) in cases {
576 let n = OracleNumber::inline(coeff, scale, is_int);
577 assert_eq!(
578 n.to_canonical_string(),
579 expect,
580 "coeff={coeff} scale={scale}"
581 );
582 assert_eq!(n.is_integer(), is_int);
583 }
584 }
585
586 #[test]
587 fn from_canonical_text_round_trips() {
588 for text in [
589 "0",
590 "1",
591 "-1",
592 "0.5",
593 "100",
594 "0.001",
595 "12345678901234567890",
596 ] {
597 let n = OracleNumber::from_canonical_text(text);
598 assert_eq!(n.to_canonical_string(), text);
599 }
600 }
601
602 #[test]
603 fn from_canonical_text_matches_wire_decoder_for_trailing_zero_integers() {
604 for text in ["10", "100", "-1000", "1000000000000000000"] {
605 let wire = encode_number_text(text).expect("encode trailing-zero integer");
606 let from_wire = OracleNumber::from_wire(&wire).expect("decode trailing-zero integer");
607 let from_text = OracleNumber::from_canonical_text(text);
608 assert_eq!(
609 from_text, from_wire,
610 "canonical text materialization should match wire decode for {text}"
611 );
612 assert_eq!(from_text.to_canonical_string(), text);
613 }
614 }
615
616 #[test]
617 fn owned_and_borrowed_number_decode_agree_for_large_integers() {
618 for text in [
619 "1",
620 "100",
621 "1000000000000000000", // 19 digits
622 "99999999999999999999999999999999999999", // 38 nines (inline max)
623 "100000000000000000000000000000000000000", // 1e38 (39 digits)
624 "1000000000000000000000000000000000000000", // 1e39 (40 digits)
625 "10000000000000000000000000000000000000000", // 1e40
626 ] {
627 let wire = match encode_number_text(text) {
628 Ok(wire) => wire,
629 Err(err) => panic!("encode {text}: {err:?}"),
630 };
631 // Owned path:
632 let owned = OracleNumber::from_wire(&wire).expect("from_wire");
633 // Borrowed path: decode wire to canonical text, then to_owned_value's
634 // from_canonical_text_with_flag.
635 let mut digits = Vec::new();
636 let mut canon = String::new();
637 let is_int =
638 crate::thin::codecs::decode_number_text_into(&wire, &mut digits, &mut canon)
639 .expect("decode_number_text_into");
640 let borrowed = OracleNumber::from_canonical_text_with_flag(&canon, is_int);
641 // Observable values must always match even when the enum variant diverges.
642 assert_eq!(
643 owned.to_canonical_string(),
644 borrowed.to_canonical_string(),
645 "canon {text}"
646 );
647 assert_eq!(owned.to_i128(), borrowed.to_i128(), "i128 {text}");
648 assert_eq!(owned.to_i64(), borrowed.to_i64(), "i64 {text}");
649 assert_eq!(owned.is_integer(), borrowed.is_integer(), "is_int {text}");
650 }
651 }
652
653 #[test]
654 fn overflow_value_falls_back_to_text_losslessly() {
655 // A 40-digit integer exceeds i128 (39 digits max); the canonical text
656 // round-trips through the boxed fallback exactly. Built via from_wire of
657 // a synthetic value would require the encoder, so assert the fallback
658 // constructor preserves the text verbatim.
659 let big = "1234567890123456789012345678901234567890"; // 40 digits
660 let n = OracleNumber::from_canonical_text(big);
661 assert!(
662 matches!(n, OracleNumber::Text { .. }),
663 "40-digit -> text fallback"
664 );
665 assert_eq!(n.to_canonical_string(), big);
666 }
667}