use eyre::{eyre, Result};
pub fn parse_decimal_amount(amount: &str, decimals: u32) -> Result<u128> {
let amount = amount.trim();
if amount.is_empty() {
return Err(eyre!("Amount is empty"));
}
if amount.starts_with('+') {
return Err(eyre!(
"Invalid amount format: {} (leading sign not allowed)",
amount
));
}
let parts: Vec<&str> = amount.split('.').collect();
let (integer_part, fractional_part) = match parts.len() {
1 => (parts[0], ""),
2 => (parts[0], parts[1]),
_ => return Err(eyre!("Invalid amount format: {}", amount)),
};
if integer_part.is_empty() && fractional_part.is_empty() {
return Err(eyre!("Invalid amount format: {} (no digits)", amount));
}
let integer: u128 = if integer_part.is_empty() {
0
} else {
integer_part
.parse()
.map_err(|_| eyre!("Invalid integer part: {}", integer_part))?
};
let fractional_str = if fractional_part.len() >= decimals as usize {
&fractional_part[..decimals as usize]
} else {
fractional_part
};
let fractional: u128 = if fractional_str.is_empty() {
0
} else {
fractional_str
.parse()
.map_err(|_| eyre!("Invalid fractional part: {}", fractional_str))?
};
let padding_zeros = decimals as usize - fractional_str.len().min(decimals as usize);
let fractional_padded = fractional
.checked_mul(10_u128.pow(padding_zeros as u32))
.ok_or_else(|| eyre!("Amount overflow: {}", amount))?;
let multiplier = 10_u128.pow(decimals);
integer
.checked_mul(multiplier)
.and_then(|v| v.checked_add(fractional_padded))
.ok_or_else(|| eyre!("Amount overflow: {}", amount))
}
pub fn parse_decimal_amount_u64(amount: &str, decimals: u32) -> Result<u64> {
let parsed = parse_decimal_amount(amount, decimals)?;
u64::try_from(parsed).map_err(|_| {
eyre!(
"Amount {} exceeds u64::MAX in base units (parsed {}, max {}). \
Try a smaller amount.",
amount,
parsed,
u64::MAX
)
})
}
pub fn format_decimal_amount(raw: u128, decimals: u32) -> String {
if decimals == 0 {
return raw.to_string();
}
let scale = 10u128.pow(decimals);
let int_part = raw / scale;
let frac_part = raw % scale;
format!(
"{}.{:0width$}",
int_part,
frac_part,
width = decimals as usize
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn integer_zero_returns_zero_for_any_decimals() {
for d in [0u32, 1, 6, 18, 30] {
assert_eq!(parse_decimal_amount("0", d).unwrap(), 0);
}
}
#[test]
fn integer_one_scales_by_decimals() {
assert_eq!(parse_decimal_amount("1", 0).unwrap(), 1);
assert_eq!(parse_decimal_amount("1", 1).unwrap(), 10);
assert_eq!(parse_decimal_amount("1", 6).unwrap(), 1_000_000);
assert_eq!(
parse_decimal_amount("1", 18).unwrap(),
1_000_000_000_000_000_000
);
}
#[test]
fn larger_integers() {
assert_eq!(parse_decimal_amount("100", 6).unwrap(), 100_000_000);
assert_eq!(parse_decimal_amount("12345", 6).unwrap(), 12_345_000_000);
}
#[test]
fn leading_zeros_in_integer_part_are_ignored() {
assert_eq!(parse_decimal_amount("00010", 6).unwrap(), 10_000_000);
assert_eq!(parse_decimal_amount("000", 6).unwrap(), 0);
}
#[test]
fn fractional_pads_short_fractions() {
assert_eq!(parse_decimal_amount("1.5", 6).unwrap(), 1_500_000);
assert_eq!(parse_decimal_amount("1.001", 6).unwrap(), 1_001_000);
assert_eq!(parse_decimal_amount("0.5", 6).unwrap(), 500_000);
}
#[test]
fn fractional_smallest_unit() {
assert_eq!(parse_decimal_amount("0.000001", 6).unwrap(), 1);
assert_eq!(parse_decimal_amount("0.000000000000000001", 18).unwrap(), 1);
}
#[test]
fn fractional_with_leading_zeros() {
assert_eq!(parse_decimal_amount("0.001", 6).unwrap(), 1_000);
assert_eq!(parse_decimal_amount("0.0001", 6).unwrap(), 100);
}
#[test]
fn fractional_trailing_zeros_no_op() {
assert_eq!(parse_decimal_amount("1.500000", 6).unwrap(), 1_500_000);
assert_eq!(parse_decimal_amount("1.5", 6).unwrap(), 1_500_000);
assert_eq!(parse_decimal_amount("1.50", 6).unwrap(), 1_500_000);
}
#[test]
fn fractional_exact_length_no_padding_no_truncation() {
assert_eq!(parse_decimal_amount("0.123456", 6).unwrap(), 123_456);
assert_eq!(parse_decimal_amount("9.999999", 6).unwrap(), 9_999_999);
}
#[test]
fn bare_fraction_no_integer() {
assert_eq!(parse_decimal_amount(".5", 6).unwrap(), 500_000);
assert_eq!(parse_decimal_amount(".000001", 6).unwrap(), 1);
}
#[test]
fn trailing_dot_no_fraction() {
assert_eq!(parse_decimal_amount("1.", 6).unwrap(), 1_000_000);
}
#[test]
fn truncates_excess_precision_does_not_round() {
assert_eq!(parse_decimal_amount("1.0000001", 6).unwrap(), 1_000_000);
assert_eq!(parse_decimal_amount("1.1234567", 6).unwrap(), 1_123_456);
assert_eq!(parse_decimal_amount("0.9999999", 6).unwrap(), 999_999);
}
#[test]
fn truncates_long_fraction_string() {
let long = format!("0.{}", "1".repeat(60));
assert_eq!(parse_decimal_amount(&long, 6).unwrap(), 111_111);
}
#[test]
fn decimals_zero_passes_integer_through() {
assert_eq!(parse_decimal_amount("0", 0).unwrap(), 0);
assert_eq!(parse_decimal_amount("1", 0).unwrap(), 1);
assert_eq!(parse_decimal_amount("42", 0).unwrap(), 42);
}
#[test]
fn decimals_zero_truncates_any_fractional_input() {
assert_eq!(parse_decimal_amount("1.999999", 0).unwrap(), 1);
assert_eq!(parse_decimal_amount("0.5", 0).unwrap(), 0);
}
#[test]
fn whitespace_around_amount_is_trimmed() {
assert_eq!(parse_decimal_amount(" 1.5 ", 6).unwrap(), 1_500_000);
assert_eq!(parse_decimal_amount("\t10\n", 6).unwrap(), 10_000_000);
}
#[test]
fn whitespace_inside_amount_rejected() {
assert!(parse_decimal_amount("1 0", 6).is_err());
assert!(parse_decimal_amount("1. 5", 6).is_err());
assert!(parse_decimal_amount("1 .5", 6).is_err());
}
#[test]
fn rejects_alphabetic() {
assert!(parse_decimal_amount("abc", 6).is_err());
assert!(parse_decimal_amount("1a", 6).is_err());
assert!(parse_decimal_amount("a1", 6).is_err());
assert!(parse_decimal_amount("1.a", 6).is_err());
}
#[test]
fn rejects_multiple_decimal_points() {
assert!(parse_decimal_amount("1.2.3", 6).is_err());
assert!(parse_decimal_amount("..5", 6).is_err());
assert!(parse_decimal_amount("1..5", 6).is_err());
}
#[test]
fn rejects_negatives() {
assert!(parse_decimal_amount("-1", 6).is_err());
assert!(parse_decimal_amount("-0.5", 6).is_err());
assert!(parse_decimal_amount("-.5", 6).is_err());
}
#[test]
fn rejects_signed_positive_prefix() {
assert!(parse_decimal_amount("+1", 6).is_err());
assert!(parse_decimal_amount("+0.5", 6).is_err());
}
#[test]
fn rejects_scientific_notation() {
assert!(parse_decimal_amount("1e6", 6).is_err());
assert!(parse_decimal_amount("1E6", 6).is_err());
assert!(parse_decimal_amount("1.5e2", 6).is_err());
}
#[test]
fn rejects_thousands_separator() {
assert!(parse_decimal_amount("1,000", 6).is_err());
assert!(parse_decimal_amount("1_000", 6).is_err());
}
#[test]
fn rejects_hex_octal_binary_prefixes() {
assert!(parse_decimal_amount("0x10", 6).is_err());
assert!(parse_decimal_amount("0o10", 6).is_err());
assert!(parse_decimal_amount("0b10", 6).is_err());
}
#[test]
fn rejects_empty_and_blank() {
assert!(parse_decimal_amount("", 6).is_err());
assert!(parse_decimal_amount(" ", 6).is_err());
assert!(parse_decimal_amount(".", 6).is_err());
}
#[test]
fn rejects_non_ascii_digits() {
assert!(parse_decimal_amount("١", 6).is_err()); }
#[test]
fn integer_overflow_in_u128_multiply() {
let huge = "1".to_string() + &"0".repeat(38); assert!(parse_decimal_amount(&huge, 6).is_err());
}
#[test]
fn integer_then_fraction_overflow_in_u128_add() {
let near_max = "340282366920938463463374607431768211455"; assert!(parse_decimal_amount(near_max, 1).is_err());
}
#[test]
fn rejects_decimals_too_large_for_u128_multiplier() {
}
#[test]
fn u64_at_boundary_succeeds() {
assert_eq!(
parse_decimal_amount_u64("18", 18).unwrap(),
18_000_000_000_000_000_000
);
assert_eq!(
parse_decimal_amount_u64("18446744073709551615", 0).unwrap(),
u64::MAX
);
}
#[test]
fn u64_overflow_returns_clear_error() {
let err = parse_decimal_amount_u64("100", 18).unwrap_err().to_string();
assert!(err.contains("exceeds u64::MAX"), "got: {err}");
assert!(err.contains("100"), "should include the input: {err}");
}
#[test]
fn u64_at_max_plus_one_overflows() {
assert!(parse_decimal_amount_u64("18446744073709551616", 0).is_err());
}
#[test]
fn u64_propagates_underlying_parse_errors() {
assert!(parse_decimal_amount_u64("abc", 6).is_err());
assert!(parse_decimal_amount_u64("-1", 6).is_err());
assert!(parse_decimal_amount_u64("", 6).is_err());
}
#[test]
fn integer_part_round_trips_for_realistic_ranges() {
for &integer in &[0u128, 1, 7, 100, 1_000, 1_000_000, 999_999_999] {
for &decimals in &[0u32, 1, 6, 9, 18] {
let input = integer.to_string();
let expected = integer * 10_u128.pow(decimals);
assert_eq!(
parse_decimal_amount(&input, decimals).unwrap(),
expected,
"round-trip failed for {input} with {decimals} decimals",
);
}
}
}
#[test]
fn fractional_round_trip_at_exact_decimal_length() {
let cases: &[(&str, u32, u128)] = &[
("0.5", 1, 5),
("0.50", 2, 50),
("0.500000", 6, 500_000),
("12.345", 3, 12_345),
("12.345000", 6, 12_345_000),
("0.123456789012345678", 18, 123_456_789_012_345_678),
];
for &(input, decimals, expected) in cases {
assert_eq!(
parse_decimal_amount(input, decimals).unwrap(),
expected,
"case {input} with {decimals} decimals",
);
}
}
#[test]
fn truncation_is_lossy_in_the_documented_direction() {
assert_eq!(parse_decimal_amount("1.0999999", 6).unwrap(), 1_099_999);
assert_eq!(parse_decimal_amount("0.0000004", 6).unwrap(), 0);
assert_eq!(parse_decimal_amount("0.0000009", 6).unwrap(), 0);
}
}