#[must_use]
pub fn format(
value: f64,
decimal_sep: &str,
decimal_pos: Option<usize>,
grouping: usize,
thousand_sep: &str,
) -> String {
if !value.is_finite() {
return value.to_string();
}
let signed = match decimal_pos {
Some(p) => round_half_up(value, p),
None => format!("{}", if value == 0.0 { 0.0 } else { value }),
};
let negative = signed.starts_with('-');
let formatted = signed.trim_start_matches('-').to_owned();
let (int_part, frac_part) = match formatted.split_once('.') {
Some((i, f)) => (i.to_owned(), Some(f.to_owned())),
None => (formatted, None),
};
let grouped = if grouping > 0 && !thousand_sep.is_empty() {
group_digits(&int_part, grouping, thousand_sep)
} else {
int_part
};
let mut out =
String::with_capacity(grouped.len() + frac_part.as_deref().map_or(0, str::len) + 4);
if negative {
out.push('-');
}
out.push_str(&grouped);
if let Some(frac) = frac_part {
out.push_str(decimal_sep);
out.push_str(&frac);
}
out
}
#[must_use]
pub fn floatformat(value: f64, precision: i64) -> String {
let abs = precision.unsigned_abs() as usize;
let drop_trailing = precision <= 0;
let formatted = round_half_up(value, abs);
if drop_trailing {
if let Some((int_part, frac_part)) = formatted.split_once('.') {
if frac_part.chars().all(|c| c == '0') {
return int_part.to_owned();
}
}
}
formatted
}
pub(crate) fn round_half_up(value: f64, decimals: usize) -> String {
if !value.is_finite() {
return value.to_string();
}
let shortest = format!("{}", value.abs());
let (int_part, frac_part) = shortest.split_once('.').unwrap_or((&shortest, ""));
let mut digits: Vec<u8> = int_part
.bytes()
.chain(
frac_part
.bytes()
.chain(std::iter::repeat(b'0'))
.take(decimals),
)
.collect();
if frac_part
.as_bytes()
.get(decimals)
.is_some_and(|d| *d >= b'5')
{
let mut i = digits.len();
loop {
if i == 0 {
digits.insert(0, b'1');
break;
}
i -= 1;
if digits[i] == b'9' {
digits[i] = b'0';
} else {
digits[i] += 1;
break;
}
}
}
let int_len = digits.len() - decimals;
let mut out = String::with_capacity(digits.len() + 2);
if value < 0.0 && digits.iter().any(|d| *d != b'0') {
out.push('-');
}
out.push_str(std::str::from_utf8(&digits[..int_len]).unwrap_or("0"));
if decimals > 0 {
out.push('.');
out.push_str(std::str::from_utf8(&digits[int_len..]).unwrap_or(""));
}
out
}
#[must_use]
pub fn format_i64(value: i64, grouping: usize, thousand_sep: &str) -> String {
let negative = value < 0;
let abs = value.unsigned_abs().to_string();
let grouped = if grouping > 0 && !thousand_sep.is_empty() {
group_digits(&abs, grouping, thousand_sep)
} else {
abs
};
if negative {
format!("-{grouped}")
} else {
grouped
}
}
pub(crate) fn group_digits(digits: &str, n: usize, sep: &str) -> String {
if digits.len() <= n {
return digits.to_owned();
}
let mut out = String::with_capacity(digits.len() + (digits.len() / n) * sep.len());
let first_group_len = digits.len() % n;
let mut i = 0;
if first_group_len > 0 {
out.push_str(&digits[..first_group_len]);
i = first_group_len;
}
while i < digits.len() {
if !out.is_empty() {
out.push_str(sep);
}
out.push_str(&digits[i..i + n]);
i += n;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_simple_no_grouping_no_decimal_round() {
assert_eq!(format(1234.567, ".", None, 0, ""), "1234.567");
}
#[test]
fn format_integer_value_no_decimal_part() {
assert_eq!(format(100.0, ".", None, 0, ""), "100");
}
#[test]
fn format_with_fixed_decimal_pos() {
assert_eq!(format(1.5, ".", Some(2), 0, ""), "1.50");
assert_eq!(format(1.5, ".", Some(4), 0, ""), "1.5000");
}
#[test]
fn format_decimal_pos_rounds() {
let out = format(1.555, ".", Some(2), 0, "");
assert!(out.starts_with("1.5"), "got: {out:?}");
assert_eq!(out.chars().count(), 4, "got: {out:?}");
assert_eq!(format(1.567, ".", Some(2), 0, ""), "1.57");
assert_eq!(format(1.234, ".", Some(2), 0, ""), "1.23");
}
#[test]
fn format_decimal_pos_zero_drops_fraction() {
assert_eq!(format(1234.7, ".", Some(0), 0, ""), "1235");
}
#[test]
fn format_thousands_grouping_en_us() {
assert_eq!(format(1_234_567.89, ".", Some(2), 3, ","), "1,234,567.89");
}
#[test]
fn format_thousands_grouping_de_de() {
assert_eq!(format(1_234_567.89, ",", Some(2), 3, "."), "1.234.567,89");
}
#[test]
fn format_thousands_grouping_fr_fr_nbsp() {
assert_eq!(
format(1_234_567.0, ",", Some(0), 3, "\u{00A0}"),
"1\u{00A0}234\u{00A0}567"
);
}
#[test]
fn format_below_grouping_threshold() {
assert_eq!(format(999.0, ".", Some(0), 3, ","), "999");
}
#[test]
fn format_exactly_at_grouping_threshold() {
assert_eq!(format(1000.0, ".", Some(0), 3, ","), "1,000");
}
#[test]
fn format_negative_preserves_sign() {
assert_eq!(format(-1234.56, ".", Some(2), 3, ","), "-1,234.56");
assert_eq!(format(-100.0, ".", Some(0), 0, ""), "-100");
}
#[test]
fn format_nan_uses_rust_display() {
assert_eq!(format(f64::NAN, ".", Some(2), 0, ""), "NaN");
}
#[test]
fn format_infinity_uses_rust_display() {
assert_eq!(format(f64::INFINITY, ".", Some(2), 0, ""), "inf");
assert_eq!(format(f64::NEG_INFINITY, ".", Some(2), 0, ""), "-inf");
}
#[test]
fn format_empty_thousand_sep_disables_grouping() {
assert_eq!(format(1234567.0, ".", Some(0), 3, ""), "1234567");
}
#[test]
fn format_i64_small_no_grouping() {
assert_eq!(format_i64(42, 0, ""), "42");
}
#[test]
fn format_i64_grouped() {
assert_eq!(format_i64(1_234_567, 3, ","), "1,234,567");
assert_eq!(format_i64(1_000, 3, "."), "1.000");
}
#[test]
fn format_i64_negative() {
assert_eq!(format_i64(-1_234_567, 3, ","), "-1,234,567");
assert_eq!(format_i64(-1, 3, ","), "-1");
}
#[test]
fn format_i64_zero() {
assert_eq!(format_i64(0, 3, ","), "0");
}
#[test]
fn format_i64_min_does_not_panic() {
let s = format_i64(i64::MIN, 3, ",");
assert!(s.starts_with('-'));
assert!(s.contains(','));
}
#[test]
fn group_digits_canonical_examples() {
assert_eq!(group_digits("1234567", 3, ","), "1,234,567");
assert_eq!(group_digits("1234567", 3, "."), "1.234.567");
assert_eq!(group_digits("100", 3, ","), "100");
assert_eq!(group_digits("1000", 3, ","), "1,000");
assert_eq!(group_digits("999999999", 3, ","), "999,999,999");
}
#[test]
fn group_digits_empty_input() {
assert_eq!(group_digits("", 3, ","), "");
}
#[test]
fn group_digits_smaller_than_one_group() {
assert_eq!(group_digits("12", 3, ","), "12");
}
#[test]
fn floatformat_default_precision_drops_zero_decimal() {
assert_eq!(floatformat(34.23234, -1), "34.2");
assert_eq!(floatformat(34.0, -1), "34");
assert_eq!(floatformat(34.5, -1), "34.5");
}
#[test]
fn floatformat_positive_precision_keeps_trailing_zeros() {
assert_eq!(floatformat(34.23234, 3), "34.232");
assert_eq!(floatformat(34.0, 3), "34.000");
assert_eq!(floatformat(34.5, 2), "34.50");
}
#[test]
fn floatformat_negative_precision_drops_trailing_zeros() {
assert_eq!(floatformat(34.23234, -3), "34.232");
assert_eq!(floatformat(34.0, -3), "34");
assert_eq!(floatformat(5.5, -2), "5.50");
assert_eq!(floatformat(5.0, -2), "5");
}
#[test]
fn floatformat_zero_precision_no_decimals() {
assert_eq!(floatformat(34.4, 0), "34");
assert_eq!(floatformat(34.6, 0), "35");
assert_eq!(floatformat(34.0, 0), "34");
}
#[test]
fn floatformat_rounds_to_nearest() {
assert_eq!(floatformat(1.234, 2), "1.23");
assert_eq!(floatformat(1.236, 2), "1.24");
}
#[test]
fn floatformat_negative_values() {
assert_eq!(floatformat(-34.5, -1), "-34.5");
assert_eq!(floatformat(-34.0, -1), "-34");
assert_eq!(floatformat(-1.2345, 2), "-1.23");
}
#[test]
fn floatformat_rounds_half_up_and_drops_negative_zero() {
assert_eq!(floatformat(0.125, 2), "0.13");
assert_eq!(floatformat(2.5, 0), "3");
assert_eq!(floatformat(-2.5, 0), "-3");
assert_eq!(floatformat(-0.4, 0), "0");
assert_eq!(floatformat(9.995, 2), "10.00");
assert_eq!(format(-0.001, ".", Some(2), 0, ""), "0.00");
assert_eq!(format(-0.0, ".", None, 0, ""), "0");
}
}