use super::linear::{self, decimal_precision};
const SI_THOUSAND: f64 = 1_000.0;
const SI_MILLION: f64 = 1_000_000.0;
const SI_BILLION: f64 = 1_000_000_000.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NumberFormat {
Plain,
Thousands,
Si,
Percent,
Currency(&'static str),
}
impl Default for NumberFormat {
fn default() -> Self {
NumberFormat::Thousands
}
}
fn format_si(value: f64) -> String {
let abs = value.abs();
let (scaled, suffix) = if abs >= SI_BILLION {
(value / SI_BILLION, "B")
} else if abs >= SI_MILLION {
(value / SI_MILLION, "M")
} else if abs >= SI_THOUSAND {
(value / SI_THOUSAND, "k")
} else {
(value, "")
};
let mut formatted = format!("{scaled:.1}");
if let Some(stripped) = formatted.strip_suffix(".0") {
formatted = stripped.to_owned();
}
format!("{formatted}{suffix}")
}
impl NumberFormat {
pub fn format(&self, value: f64, step: f64) -> String {
match self {
NumberFormat::Plain => {
let precision = decimal_precision(step);
format!("{value:.precision$}")
}
NumberFormat::Thousands => linear::format_value(value, step),
NumberFormat::Si => format_si(value),
NumberFormat::Percent => format!("{}%", linear::format_value(value * 100.0, (step * 100.0).abs())),
NumberFormat::Currency(symbol) => format!("{symbol}{}", linear::format_value(value, step)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_matches_current_thousands_output() {
for (value, step) in [(1_234_567.891, 1.0), (-1_234.5, 1.0), (42.0, 1.0), (0.0, 0.5), (-0.005, 0.001)] {
assert_eq!(NumberFormat::default().format(value, step), linear::format_value(value, step));
}
assert_eq!(NumberFormat::default(), NumberFormat::Thousands);
}
#[test]
fn plain_has_no_thousands_grouping_unlike_thousands() {
assert_eq!(NumberFormat::Plain.format(1_234_567.89, 1.0), "1234567.89");
assert_eq!(NumberFormat::Thousands.format(1_234_567.89, 1.0), "1,234,567.89");
}
#[test]
fn si_golden_values_below_and_across_every_threshold() {
assert_eq!(NumberFormat::Si.format(850.0, 1.0), "850");
assert_eq!(NumberFormat::Si.format(1_234.0, 1.0), "1.2k");
assert_eq!(NumberFormat::Si.format(-4_200.0, 1.0), "-4.2k");
assert_eq!(NumberFormat::Si.format(1_500_000.0, 1.0), "1.5M");
assert_eq!(NumberFormat::Si.format(2_300_000_000.0, 1.0), "2.3B");
assert_eq!(NumberFormat::Si.format(0.0, 1.0), "0");
}
#[test]
fn si_drops_a_trailing_dot_zero_but_keeps_a_real_fraction() {
assert_eq!(NumberFormat::Si.format(2_000.0, 1.0), "2k");
assert_eq!(NumberFormat::Si.format(2_500.0, 1.0), "2.5k");
}
#[test]
fn percent_multiplies_by_100_and_appends_the_sign() {
assert_eq!(NumberFormat::Percent.format(0.1234, 0.00005), "12.340%");
assert_eq!(NumberFormat::Percent.format(-0.05, 0.01), "-5.00%");
assert_eq!(NumberFormat::Percent.format(1.0, 0.1), "100.00%");
}
#[test]
fn currency_prefixes_the_grouped_decimal() {
assert_eq!(NumberFormat::Currency("$").format(1_234.5, 1.0), "$1,234.50");
assert_eq!(NumberFormat::Currency("EUR ").format(-99.9, 0.1), "EUR -99.90");
}
#[test]
fn every_variant_is_finite_and_non_panicking_for_degenerate_step() {
for format in [NumberFormat::Plain, NumberFormat::Thousands, NumberFormat::Si, NumberFormat::Percent, NumberFormat::Currency("$")] {
let s = format.format(42.0, 0.0);
assert!(!s.is_empty(), "{format:?} must produce a non-empty label even for a degenerate step");
}
}
}