use rust_decimal::Decimal;
#[must_use]
pub fn add_python_scale(a: Decimal, b: Decimal) -> Decimal {
let mut sum = a + b;
let target = a.scale().max(b.scale());
if sum.scale() < target {
sum.rescale(target);
}
sum
}
#[must_use]
pub fn sub_python_scale(a: Decimal, b: Decimal) -> Decimal {
let mut diff = a - b;
let target = a.scale().max(b.scale());
if diff.scale() < target {
diff.rescale(target);
}
diff
}
#[must_use]
pub fn checked_add_python_scale(a: Decimal, b: Decimal) -> Option<Decimal> {
let mut sum = a.checked_add(b)?;
let target = a.scale().max(b.scale());
if sum.scale() < target {
sum.rescale(target);
}
Some(sum)
}
#[must_use]
pub fn checked_sub_python_scale(a: Decimal, b: Decimal) -> Option<Decimal> {
let mut diff = a.checked_sub(b)?;
let target = a.scale().max(b.scale());
if diff.scale() < target {
diff.rescale(target);
}
Some(diff)
}
#[must_use]
pub fn checked_div_python_scale(a: Decimal, b: Decimal) -> Option<Decimal> {
let quotient = a.checked_div(b)?;
let ideal_scale = i64::from(a.scale()) - i64::from(b.scale());
let mut result = quotient.normalize();
let target = ideal_scale.max(i64::from(result.scale()));
if let Ok(target) = u32::try_from(target)
&& result.scale() < target
{
result.rescale(target);
}
Some(result)
}
#[must_use]
pub fn negate_python(number: Decimal) -> Decimal {
if number.is_zero() {
number.abs()
} else {
-number
}
}
#[must_use]
pub fn round_dp_python(number: Decimal, dp: u32) -> Decimal {
let mut rounded = number.round_dp(dp);
rounded.rescale(dp);
if rounded.is_zero() && number.is_sign_negative() {
return -rounded;
}
rounded
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
use std::str::FromStr;
#[test]
fn a_zero_operand_keeps_its_scale() {
assert_eq!(add_python_scale(dec!(0.00), dec!(1)).to_string(), "1.00");
assert_eq!(add_python_scale(dec!(1), dec!(0.00)).to_string(), "1.00");
assert_eq!(sub_python_scale(dec!(0.00), dec!(1)).to_string(), "-1.00");
assert_eq!(sub_python_scale(dec!(1), dec!(0.00)).to_string(), "1.00");
}
#[test]
fn decimal_equality_cannot_see_the_bug_but_rendering_can() {
assert_eq!(dec!(1), dec!(1.00), "== ignores scale");
assert_eq!((dec!(0.00) + dec!(1)).to_string(), "1", "the bug itself");
assert_eq!(add_python_scale(dec!(0.00), dec!(1)).to_string(), "1.00");
}
#[test]
fn non_zero_operands_are_unchanged() {
for (a, b, want) in [
(dec!(2.00), dec!(1), "3.00"),
(dec!(1), dec!(2.00), "3.00"),
(dec!(2.50), dec!(2.50), "5.00"),
(dec!(111.11), dec!(-111.11), "0.00"),
(dec!(1), dec!(2), "3"),
] {
assert_eq!(add_python_scale(a, b).to_string(), want, "{a} + {b}");
}
}
#[test]
fn never_truncates() {
assert_eq!(add_python_scale(dec!(0.5), dec!(0.25)).to_string(), "0.75");
assert_eq!(
add_python_scale(dec!(0), dec!(1.2345)).to_string(),
"1.2345"
);
}
#[test]
fn accumulating_through_zero_keeps_the_widest_scale() {
let terms = [
dec!(-1),
dec!(1),
dec!(-111.11),
dec!(111.11),
dec!(-2),
dec!(2),
];
let mut python_like = Decimal::ZERO;
let mut naive = Decimal::ZERO;
for t in terms {
python_like = add_python_scale(python_like, t);
naive += t;
}
assert_eq!(python_like.to_string(), "0.00");
assert_eq!(naive.to_string(), "0", "pins the pre-fix behavior");
}
#[test]
fn division_matches_python_decimal_scale() {
let cases = [
("0.00", "4", "0.00"),
("0.000", "3", "0.000"),
("0.0", "7", "0.0"),
("0.00", "2.0", "0.0"),
("0.00", "1", "0.00"),
("-0.00", "3", "0.00"), ("0", "4", "0"),
("7", "2", "3.5"),
("5", "4", "1.25"),
("1.0", "2.00", "0.5"),
("1.00", "2", "0.50"),
("3.00", "3", "1.00"),
("1.000", "8", "0.125"),
("10.00", "4", "2.50"),
("2.50", "5", "0.50"),
("100.00", "8", "12.50"),
("12.345", "5", "2.469"),
("1", "3", "0.3333333333333333333333333333"),
];
for (a, b, want) in cases {
let a = Decimal::from_str(a).expect("dividend parses");
let b = Decimal::from_str(b).expect("divisor parses");
let got = checked_div_python_scale(a, b).expect("no overflow");
assert_eq!(got.to_string(), want, "{a} / {b}");
}
}
#[test]
fn from_str_drops_the_sign_on_zero_though_the_type_can_carry_one() {
let parsed = Decimal::from_str("-0.00").expect("parses");
assert_eq!(parsed.to_string(), "0.00", "from_str drops it");
assert!(!parsed.is_sign_negative());
assert_eq!((-Decimal::from_str("0.00").unwrap()).to_string(), "-0.00");
assert_eq!(
checked_div_python_scale(parsed, Decimal::from(3))
.expect("no overflow")
.to_string(),
"0.00",
);
}
#[test]
fn division_by_zero_is_none() {
assert_eq!(
checked_div_python_scale(dec!(1.00), Decimal::ZERO),
None,
"div-by-zero must not panic",
);
}
#[test]
fn negating_a_zero_gives_an_unsigned_zero() {
assert_eq!((-dec!(0.00)).to_string(), "-0.00", "the bug itself");
assert_eq!(negate_python(dec!(0.00)).to_string(), "0.00");
assert_eq!(negate_python(-dec!(0.00)).to_string(), "0.00");
assert_eq!(negate_python(dec!(0.0000)).to_string(), "0.0000");
assert_eq!(negate_python(dec!(1.25)).to_string(), "-1.25");
assert_eq!(negate_python(dec!(-1.25)).to_string(), "1.25");
}
#[test]
fn rounding_a_small_negative_to_zero_keeps_the_sign() {
assert_eq!(dec!(-0.00495).round_dp(2).to_string(), "0.00", "the bug");
for (value, dp, want) in [
(dec!(-0.00495), 2, "-0.00"),
(dec!(-0.004), 2, "-0.00"),
(dec!(-0.0000001), 2, "-0.00"),
(dec!(0.00495), 2, "0.00"),
(dec!(-1.004), 2, "-1.00"),
(dec!(-0.00495), 5, "-0.00495"),
(dec!(1), 2, "1.00"),
] {
assert_eq!(
round_dp_python(value, dp).to_string(),
want,
"{value} at {dp}dp",
);
}
}
#[test]
fn checked_variants_return_none_on_overflow() {
assert_eq!(checked_add_python_scale(Decimal::MAX, Decimal::MAX), None);
assert_eq!(checked_sub_python_scale(Decimal::MIN, Decimal::MAX), None);
assert_eq!(
checked_add_python_scale(dec!(0.00), dec!(1))
.expect("no overflow")
.to_string(),
"1.00"
);
assert_eq!(
checked_sub_python_scale(dec!(1), dec!(0.00))
.expect("no overflow")
.to_string(),
"1.00"
);
}
#[test]
fn rescale_beyond_capacity_preserves_the_value() {
let near_max = Decimal::MAX - Decimal::ONE;
let sum = add_python_scale(near_max, dec!(0.00));
assert_eq!(
sum, near_max,
"a value too large to carry the target scale must keep its VALUE",
);
assert_eq!(sum.to_string(), near_max.to_string());
}
}