1#![cfg_attr(docsrs, feature(doc_cfg))]
10#![forbid(unsafe_code)]
11#![warn(missing_docs)]
12
13use std::{borrow::Cow, str::FromStr};
14
15mod constrained;
16
17pub use constrained::{DecimalConstraintError, NonNegativeDecimal, PositiveDecimal, Ratio};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum RoundingStrategy {
23 MidpointNearestEven,
25 MidpointAwayFromZero,
27 MidpointTowardZero,
29 ToZero,
31 AwayFromZero,
33 ToNegativeInfinity,
35 ToPositiveInfinity,
37}
38
39#[cfg(not(feature = "bigdecimal"))]
40mod backend {
41 use super::{
42 FromStr, RoundingStrategy, rust_decimal_to_i128_mantissa, rust_decimal_to_scaled_units,
43 };
44
45 pub use rust_decimal::Decimal;
46 use rust_decimal::RoundingStrategy as RustRoundingStrategy;
47 pub use rust_decimal::prelude::ToPrimitive;
48
49 pub fn parse_decimal(value: &str) -> Option<Decimal> {
50 Decimal::from_str(value).ok()
51 }
52
53 pub const MAX_DECIMAL_PRECISION: u8 = 28;
54
55 pub const fn clone_decimal(value: &Decimal) -> Decimal {
56 *value
57 }
58
59 pub fn fractional_digit_count(value: &Decimal) -> i64 {
60 i64::from(value.scale())
61 }
62
63 pub const fn zero() -> Decimal {
64 Decimal::ZERO
65 }
66
67 pub const fn one() -> Decimal {
68 Decimal::ONE
69 }
70
71 pub fn from_minor_units(value: i128, scale: u32) -> Decimal {
72 Decimal::from_i128_with_scale(value, scale)
73 }
74
75 pub fn try_from_scaled_units(value: i128, scale: u32) -> Option<Decimal> {
76 Decimal::try_from_i128_with_scale(value, scale).ok()
77 }
78
79 pub fn round_dp_with_strategy(
80 value: &Decimal,
81 scale: u32,
82 strategy: RoundingStrategy,
83 ) -> Decimal {
84 let strategy: RustRoundingStrategy = strategy.into();
85 value.round_dp_with_strategy(scale, strategy)
86 }
87
88 pub fn to_plain_string(value: &Decimal) -> String {
89 value.to_string()
90 }
91
92 pub fn checked_add(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
93 lhs.checked_add(*rhs)
94 }
95
96 pub fn checked_sub(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
97 lhs.checked_sub(*rhs)
98 }
99
100 pub fn checked_mul(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
101 lhs.checked_mul(*rhs)
102 }
103
104 pub fn checked_div(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
105 lhs.checked_div(*rhs)
106 }
107
108 pub fn try_to_scaled_units(value: &Decimal, target_scale: u32) -> Option<i128> {
109 rust_decimal_to_scaled_units(value, target_scale)
110 }
111
112 pub fn try_to_i128_mantissa(value: &Decimal, target_scale: u32) -> Option<i128> {
113 rust_decimal_to_i128_mantissa(value, target_scale)
114 }
115
116 impl From<RoundingStrategy> for RustRoundingStrategy {
117 fn from(value: RoundingStrategy) -> Self {
118 match value {
119 RoundingStrategy::MidpointNearestEven => Self::MidpointNearestEven,
120 RoundingStrategy::MidpointAwayFromZero => Self::MidpointAwayFromZero,
121 RoundingStrategy::MidpointTowardZero => Self::MidpointTowardZero,
122 RoundingStrategy::ToZero => Self::ToZero,
123 RoundingStrategy::AwayFromZero => Self::AwayFromZero,
124 RoundingStrategy::ToNegativeInfinity => Self::ToNegativeInfinity,
125 RoundingStrategy::ToPositiveInfinity => Self::ToPositiveInfinity,
126 }
127 }
128 }
129}
130
131#[cfg(feature = "bigdecimal")]
132mod backend {
133 use super::{DECIMAL128_PRECISION, FromStr, RoundingStrategy};
134
135 pub use bigdecimal::BigDecimal as Decimal;
136 use bigdecimal::RoundingMode;
137 use num_bigint::BigInt;
138 pub use num_traits::ToPrimitive;
139 use num_traits::{One, Zero};
140
141 pub fn parse_decimal(value: &str) -> Option<Decimal> {
142 Decimal::from_str(value).ok()
143 }
144
145 pub const MAX_DECIMAL_PRECISION: u8 = u8::MAX;
146
147 pub fn clone_decimal(value: &Decimal) -> Decimal {
148 value.clone()
149 }
150
151 pub fn fractional_digit_count(value: &Decimal) -> i64 {
152 value.fractional_digit_count()
153 }
154
155 pub fn zero() -> Decimal {
156 Decimal::zero()
157 }
158
159 pub fn one() -> Decimal {
160 Decimal::one()
161 }
162
163 pub fn from_minor_units(value: i128, scale: u32) -> Decimal {
164 Decimal::new(BigInt::from(value), i64::from(scale))
165 }
166
167 #[expect(
168 clippy::unnecessary_wraps,
169 reason = "bigdecimal accepts every i128 coefficient and u32 scale, but the backend API mirrors rust_decimal"
170 )]
171 pub fn try_from_scaled_units(value: i128, scale: u32) -> Option<Decimal> {
172 Some(Decimal::new(BigInt::from(value), i64::from(scale)))
173 }
174
175 pub fn round_dp_with_strategy(
176 value: &Decimal,
177 scale: u32,
178 strategy: RoundingStrategy,
179 ) -> Decimal {
180 let mode = match strategy {
181 RoundingStrategy::MidpointNearestEven => RoundingMode::HalfEven,
182 RoundingStrategy::MidpointAwayFromZero => RoundingMode::HalfUp,
183 RoundingStrategy::MidpointTowardZero => RoundingMode::HalfDown,
184 RoundingStrategy::ToZero => RoundingMode::Down,
185 RoundingStrategy::AwayFromZero => RoundingMode::Up,
186 RoundingStrategy::ToNegativeInfinity => RoundingMode::Floor,
187 RoundingStrategy::ToPositiveInfinity => RoundingMode::Ceiling,
188 };
189
190 value.with_scale_round(i64::from(scale), mode)
191 }
192
193 pub fn to_plain_string(value: &Decimal) -> String {
194 value.to_plain_string()
195 }
196
197 #[expect(
198 clippy::unnecessary_wraps,
199 reason = "bigdecimal addition cannot overflow here, but the backend API mirrors rust_decimal checked arithmetic"
200 )]
201 pub fn checked_add(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
202 Some(lhs + rhs)
203 }
204
205 #[expect(
206 clippy::unnecessary_wraps,
207 reason = "bigdecimal subtraction cannot overflow here, but the backend API mirrors rust_decimal checked arithmetic"
208 )]
209 pub fn checked_sub(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
210 Some(lhs - rhs)
211 }
212
213 #[expect(
214 clippy::unnecessary_wraps,
215 reason = "bigdecimal multiplication cannot overflow here, but the backend API mirrors rust_decimal checked arithmetic"
216 )]
217 pub fn checked_mul(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
218 Some(lhs * rhs)
219 }
220
221 pub fn checked_div(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
222 if rhs.is_zero() {
223 return None;
224 }
225
226 Some(lhs / rhs)
227 }
228
229 pub fn try_to_scaled_units(value: &Decimal, target_scale: u32) -> Option<i128> {
230 let (mantissa, source_scale) = value.as_bigint_and_exponent();
231 if mantissa.is_zero() {
232 return Some(0);
233 }
234
235 let target_scale = i64::from(target_scale);
236 let units = match source_scale.cmp(&target_scale) {
237 std::cmp::Ordering::Equal => mantissa,
238 std::cmp::Ordering::Less => {
239 let diff = u32::try_from(target_scale - source_scale).ok()?;
240 mantissa * BigInt::from(10_u8).pow(diff)
241 }
242 std::cmp::Ordering::Greater => {
243 let diff = u32::try_from(source_scale - target_scale).ok()?;
244 let divisor = BigInt::from(10_u8).pow(diff);
245 if (&mantissa % &divisor) != BigInt::zero() {
246 return None;
247 }
248 mantissa / divisor
249 }
250 };
251
252 i128::try_from(units).ok()
253 }
254
255 pub fn try_to_i128_mantissa(value: &Decimal, target_scale: u32) -> Option<i128> {
256 if target_scale > DECIMAL128_PRECISION {
257 return None;
258 }
259
260 let target = i64::from(target_scale);
261 let rescaled = value.with_scale_round(target, bigdecimal::RoundingMode::HalfEven);
262 if rescaled.digits() > 38 {
263 return None;
264 }
265 let (bigint, _) = rescaled.into_bigint_and_exponent();
266 i128::try_from(bigint).ok()
267 }
268}
269
270pub use backend::{Decimal, ToPrimitive};
271
272const DECIMAL128_PRECISION: u32 = 38;
273const MAX_I128_MANTISSA: i128 = 10_i128.pow(DECIMAL128_PRECISION);
274
275#[cfg(not(feature = "bigdecimal"))]
276fn rust_decimal_to_scaled_units(value: &rust_decimal::Decimal, target_scale: u32) -> Option<i128> {
277 let source_scale = value.scale();
278 let mantissa = value.mantissa();
279 match source_scale.cmp(&target_scale) {
280 std::cmp::Ordering::Equal => Some(mantissa),
281 std::cmp::Ordering::Less => {
282 let diff = target_scale - source_scale;
283 let pow = 10_i128.checked_pow(diff)?;
284 mantissa.checked_mul(pow)
285 }
286 std::cmp::Ordering::Greater => {
287 let diff = source_scale - target_scale;
288 let pow = 10_i128.checked_pow(diff)?;
289 if mantissa % pow != 0 {
290 return None;
291 }
292 Some(mantissa / pow)
293 }
294 }
295}
296
297fn rust_decimal_to_i128_mantissa(value: &rust_decimal::Decimal, target_scale: u32) -> Option<i128> {
298 if target_scale > DECIMAL128_PRECISION {
299 return None;
300 }
301
302 let source_scale = value.scale();
303 let mantissa: i128 = value.mantissa();
304 let rescaled = match source_scale.cmp(&target_scale) {
305 std::cmp::Ordering::Equal => mantissa,
306 std::cmp::Ordering::Less => {
307 let diff = target_scale - source_scale;
308 let pow = 10_i128.checked_pow(diff)?;
309 mantissa.checked_mul(pow)?
310 }
311 std::cmp::Ordering::Greater => {
312 let diff = source_scale - target_scale;
313 let pow = 10_i128.checked_pow(diff)?.cast_unsigned();
314 let neg = mantissa < 0;
315 let abs = mantissa.unsigned_abs();
316 let q = (abs / pow).cast_signed();
317 let r = abs % pow;
318 let half = pow / 2;
319 let rounded = match r.cmp(&half) {
320 std::cmp::Ordering::Greater => q + 1,
321 std::cmp::Ordering::Less => q,
322 std::cmp::Ordering::Equal => q + (q & 1),
323 };
324 if neg { -rounded } else { rounded }
325 }
326 };
327 if rescaled.unsigned_abs() >= MAX_I128_MANTISSA.cast_unsigned() {
328 return None;
329 }
330 Some(rescaled)
331}
332
333pub const MAX_DECIMAL_PRECISION: u8 = backend::MAX_DECIMAL_PRECISION;
335
336#[must_use]
338pub const fn max_decimal_precision() -> u8 {
339 backend::MAX_DECIMAL_PRECISION
340}
341
342#[must_use]
344#[cfg_attr(
345 not(feature = "bigdecimal"),
346 expect(
347 clippy::missing_const_for_fn,
348 reason = "the public helper stays non-const because bigdecimal cloning is not const"
349 )
350)]
351pub fn clone_decimal(value: &Decimal) -> Decimal {
352 backend::clone_decimal(value)
353}
354
355#[must_use]
357pub fn fractional_digit_count(value: &Decimal) -> i64 {
358 backend::fractional_digit_count(value)
359}
360
361#[must_use]
363pub fn checked_add(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
364 backend::checked_add(lhs, rhs)
365}
366
367#[must_use]
369pub fn checked_sub(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
370 backend::checked_sub(lhs, rhs)
371}
372
373#[must_use]
375pub fn checked_mul(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
376 backend::checked_mul(lhs, rhs)
377}
378
379#[must_use]
381pub fn checked_div(lhs: &Decimal, rhs: &Decimal) -> Option<Decimal> {
382 backend::checked_div(lhs, rhs)
383}
384
385pub trait Decimal128Mantissa {
387 fn try_to_i128_mantissa(&self, target_scale: u32) -> Option<i128>;
391}
392
393impl Decimal128Mantissa for Decimal {
394 fn try_to_i128_mantissa(&self, target_scale: u32) -> Option<i128> {
395 backend::try_to_i128_mantissa(self, target_scale)
396 }
397}
398
399#[cfg(feature = "bigdecimal")]
400impl Decimal128Mantissa for rust_decimal::Decimal {
401 fn try_to_i128_mantissa(&self, target_scale: u32) -> Option<i128> {
402 rust_decimal_to_i128_mantissa(self, target_scale)
403 }
404}
405
406impl Decimal128Mantissa for NonNegativeDecimal {
407 fn try_to_i128_mantissa(&self, target_scale: u32) -> Option<i128> {
408 self.as_decimal().try_to_i128_mantissa(target_scale)
409 }
410}
411
412impl Decimal128Mantissa for PositiveDecimal {
413 fn try_to_i128_mantissa(&self, target_scale: u32) -> Option<i128> {
414 self.as_decimal().try_to_i128_mantissa(target_scale)
415 }
416}
417
418impl Decimal128Mantissa for Ratio {
419 fn try_to_i128_mantissa(&self, target_scale: u32) -> Option<i128> {
420 self.as_decimal().try_to_i128_mantissa(target_scale)
421 }
422}
423
424#[must_use]
430pub fn parse_decimal(value: &str) -> Option<Decimal> {
431 let normalized = normalize_decimal_literal(value)?;
432 backend::parse_decimal(&normalized)
433}
434
435fn normalize_decimal_literal(value: &str) -> Option<Cow<'_, str>> {
436 let trimmed = value.trim();
437 if trimmed.is_empty() {
438 return None;
439 }
440
441 let (sign, unsigned) = match trimmed.as_bytes().first() {
442 Some(b'+') => ("", &trimmed[1..]),
443 Some(b'-') => ("-", &trimmed[1..]),
444 Some(_) => ("", trimmed),
445 None => return None,
446 };
447
448 if unsigned.is_empty() {
449 return None;
450 }
451
452 let mut seen_dot = false;
453 let mut seen_digit = false;
454 for byte in unsigned.bytes() {
455 match byte {
456 b'0'..=b'9' => seen_digit = true,
457 b'.' if !seen_dot => seen_dot = true,
458 _ => return None,
459 }
460 }
461
462 if !seen_digit {
463 return None;
464 }
465
466 let needs_leading_zero = unsigned.starts_with('.');
467 let needs_trailing_zero = unsigned.ends_with('.');
468 if needs_leading_zero || needs_trailing_zero {
469 let mut normalized = String::with_capacity(trimmed.len() + 2);
470 normalized.push_str(sign);
471 if needs_leading_zero {
472 normalized.push('0');
473 }
474 normalized.push_str(unsigned);
475 if needs_trailing_zero {
476 normalized.push('0');
477 }
478 Some(Cow::Owned(normalized))
479 } else if sign == "-" {
480 Some(Cow::Borrowed(trimmed))
481 } else {
482 Some(Cow::Borrowed(unsigned))
483 }
484}
485
486#[must_use]
488#[cfg_attr(
489 not(feature = "bigdecimal"),
490 expect(
491 clippy::missing_const_for_fn,
492 reason = "the public helper stays non-const because bigdecimal zero construction is not const"
493 )
494)]
495pub fn zero() -> Decimal {
496 backend::zero()
497}
498
499#[must_use]
501#[cfg_attr(
502 not(feature = "bigdecimal"),
503 expect(
504 clippy::missing_const_for_fn,
505 reason = "the public helper stays non-const because bigdecimal one construction is not const"
506 )
507)]
508pub fn one() -> Decimal {
509 backend::one()
510}
511
512#[must_use]
520pub fn from_minor_units(value: i128, scale: u32) -> Decimal {
521 backend::from_minor_units(value, scale)
522}
523
524#[must_use]
530pub fn try_from_scaled_units(value: i128, scale: u32) -> Option<Decimal> {
531 backend::try_from_scaled_units(value, scale)
532}
533
534#[must_use]
539pub fn try_to_scaled_units(value: &Decimal, target_scale: u32) -> Option<i128> {
540 backend::try_to_scaled_units(value, target_scale)
541}
542
543#[must_use]
545pub fn round_dp_with_strategy(value: &Decimal, scale: u32, strategy: RoundingStrategy) -> Decimal {
546 backend::round_dp_with_strategy(value, scale, strategy)
547}
548
549pub mod serde {
555 use super::{Cow, Decimal};
556 use serde::{Deserialize, Serializer, de};
557
558 fn invalid_decimal<E>(value: &str) -> E
559 where
560 E: de::Error,
561 {
562 E::custom(format_args!("invalid decimal string `{value}`"))
563 }
564
565 pub mod canonical_str {
567 use super::{Cow, Decimal, Deserialize, Serializer, invalid_decimal};
568 use crate::{parse_decimal, to_canonical_string};
569
570 pub fn serialize<S>(value: &Decimal, serializer: S) -> Result<S::Ok, S::Error>
575 where
576 S: Serializer,
577 {
578 serializer.serialize_str(&to_canonical_string(value))
579 }
580
581 pub fn deserialize<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
587 where
588 D: ::serde::Deserializer<'de>,
589 {
590 let value = Cow::<str>::deserialize(deserializer)?;
591 parse_decimal(&value).ok_or_else(|| invalid_decimal(&value))
592 }
593 }
594
595 pub mod option_canonical_str {
597 use super::{Cow, Decimal, Deserialize, Serializer, invalid_decimal};
598 use crate::{parse_decimal, to_canonical_string};
599 use serde::Serialize;
600
601 pub fn serialize<S>(value: &Option<Decimal>, serializer: S) -> Result<S::Ok, S::Error>
606 where
607 S: Serializer,
608 {
609 let canonical = value.as_ref().map(to_canonical_string);
610 canonical.serialize(serializer)
611 }
612
613 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Decimal>, D::Error>
619 where
620 D: ::serde::Deserializer<'de>,
621 {
622 Option::<Cow<'de, str>>::deserialize(deserializer)?
623 .map(|value| parse_decimal(&value).ok_or_else(|| invalid_decimal(&value)))
624 .transpose()
625 }
626 }
627}
628
629#[must_use]
632pub fn to_canonical_string(value: &Decimal) -> String {
633 let zero = zero();
634 if value == &zero {
635 return "0".to_owned();
636 }
637
638 let mut repr = backend::to_plain_string(value);
639 if let Some(dot) = repr.find('.') {
640 let mut end = repr.len();
641 while end > dot + 1 && repr.as_bytes()[end - 1] == b'0' {
642 end -= 1;
643 }
644 if end == dot + 1 {
645 end -= 1;
646 }
647 repr.truncate(end);
648 }
649 repr
650}
651
652#[cfg(test)]
653mod tests {
654 use std::str::FromStr;
655
656 use super::{
657 Decimal, RoundingStrategy, checked_div, parse_decimal, round_dp_with_strategy,
658 to_canonical_string, try_from_scaled_units, try_to_scaled_units,
659 };
660
661 #[test]
662 fn parse_rejects_scientific_notation() {
663 assert!(parse_decimal("1e3").is_none());
664 assert!(parse_decimal("2E-3").is_none());
665 }
666
667 #[test]
668 fn parse_accepts_standard_forms() {
669 assert_eq!(
670 parse_decimal(" +123.4500 ").unwrap(),
671 parse_decimal("123.45").unwrap()
672 );
673 assert_eq!(
674 parse_decimal("-42.1").unwrap(),
675 Decimal::from_str("-42.1").unwrap()
676 );
677 }
678
679 #[test]
680 fn parse_uses_backend_stable_plain_decimal_grammar() {
681 for (literal, canonical) in [
682 (".5", "0.5"),
683 ("1.", "1"),
684 ("+1", "1"),
685 ("-0.00", "0"),
686 ("001.2300", "1.23"),
687 (" \t\n+001.2300\r", "1.23"),
688 ] {
689 let parsed = parse_decimal(literal).unwrap_or_else(|| panic!("{literal} should parse"));
690 assert_eq!(to_canonical_string(&parsed), canonical);
691 }
692 }
693
694 #[test]
695 fn parse_rejects_non_plain_decimal_grammar() {
696 for literal in [
697 "", " ", "+", "-", ".", "+.", "-.", "+-1", "++1", "--1", "1_000", "1e3", "2E-3", "1 2",
698 "1.2.3",
699 ] {
700 assert!(parse_decimal(literal).is_none(), "{literal} should fail");
701 }
702 }
703
704 #[test]
705 fn parse_rejects_duplicate_explicit_signs() {
706 assert!(parse_decimal("+-1").is_none());
707 assert!(parse_decimal("++1").is_none());
708 assert!(parse_decimal("+").is_none());
709 assert!(parse_decimal("+1").is_some());
710 assert!(parse_decimal("-1").is_some());
711 }
712
713 #[test]
714 fn canonical_string_trims_trailing_zeros() {
715 let value = parse_decimal("123.4500").unwrap();
716 assert_eq!(to_canonical_string(&value), "123.45");
717 let integer = parse_decimal("1000").unwrap();
718 assert_eq!(to_canonical_string(&integer), "1000");
719 }
720
721 #[test]
722 fn canonical_string_normalizes_zero_sign() {
723 let negative_zero = parse_decimal("-0.00").unwrap();
724 assert_eq!(to_canonical_string(&negative_zero), "0");
725
726 let rounded_negative_zero = round_dp_with_strategy(
727 &parse_decimal("-0.0049").unwrap(),
728 2,
729 RoundingStrategy::ToZero,
730 );
731 assert_eq!(to_canonical_string(&rounded_negative_zero), "0");
732 }
733
734 #[test]
735 fn checked_div_returns_none_for_zero_divisor() {
736 let lhs = parse_decimal("10").unwrap();
737 let zero = parse_decimal("0.00").unwrap();
738 assert!(checked_div(&lhs, &zero).is_none());
739
740 let two = parse_decimal("2").unwrap();
741 let quotient = checked_div(&lhs, &two).unwrap();
742 assert_eq!(to_canonical_string("ient), "5");
743 }
744
745 #[test]
746 fn canonical_decimal_serde_uses_strings() {
747 #[derive(::serde::Serialize, ::serde::Deserialize, PartialEq, Debug)]
748 struct Payload {
749 #[serde(with = "crate::serde::canonical_str")]
750 value: Decimal,
751 #[serde(default, with = "crate::serde::option_canonical_str")]
752 optional: Option<Decimal>,
753 }
754
755 let payload = Payload {
756 value: parse_decimal("123.4500").unwrap(),
757 optional: Some(parse_decimal("0.5000").unwrap()),
758 };
759
760 let value = serde_json::to_value(&payload).unwrap();
761 assert_eq!(value["value"], serde_json::json!("123.45"));
762 assert_eq!(value["optional"], serde_json::json!("0.5"));
763 assert_eq!(serde_json::from_value::<Payload>(value).unwrap(), payload);
764
765 let missing_optional = serde_json::json!({ "value": "+1.2300" });
766 let parsed = serde_json::from_value::<Payload>(missing_optional).unwrap();
767 assert_eq!(to_canonical_string(&parsed.value), "1.23");
768 assert_eq!(parsed.optional, None);
769 }
770
771 #[test]
772 fn try_from_scaled_units_accepts_representable_values() {
773 let value = try_from_scaled_units(123_456, 3).unwrap();
774 assert_eq!(to_canonical_string(&value), "123.456");
775 }
776
777 #[test]
778 fn try_to_scaled_units_accepts_exact_values() {
779 let value = parse_decimal("123.4560").unwrap();
780 assert_eq!(try_to_scaled_units(&value, 6), Some(123_456_000));
781 assert_eq!(try_to_scaled_units(&value, 3), Some(123_456));
782
783 let negative = parse_decimal("-1.25").unwrap();
784 assert_eq!(try_to_scaled_units(&negative, 2), Some(-125));
785 }
786
787 #[test]
788 fn try_to_scaled_units_rejects_inexact_values_instead_of_rounding() {
789 let above_half = parse_decimal("1.250001").unwrap();
790 assert_eq!(try_to_scaled_units(&above_half, 1), None);
791
792 let tie = parse_decimal("1.25").unwrap();
793 assert_eq!(try_to_scaled_units(&tie, 1), None);
794 }
795
796 #[cfg(not(feature = "bigdecimal"))]
797 #[test]
798 fn try_from_scaled_units_rejects_rust_decimal_limits() {
799 assert!(try_from_scaled_units(i128::MAX, 0).is_none());
800 assert!(try_from_scaled_units(1, 29).is_none());
801 }
802}