Skip to main content

ggplot_rs/scale/
format.rs

1//! Label formatting functions for scale breaks.
2//! Analogous to R's `scales` package (comma, percent, dollar, scientific,
3//! number, SI, ordinal, bytes).
4
5use std::sync::Arc;
6
7/// Format with comma separators for thousands (e.g., 1,234,567).
8pub fn label_comma(v: f64) -> String {
9    if v == v.round() && v.abs() < 1e15 {
10        let s = format!("{}", v as i64);
11        add_commas(&s)
12    } else {
13        let s = format!("{:.2}", v);
14        let s = s.trim_end_matches('0').trim_end_matches('.');
15        if let Some((int_part, dec_part)) = s.split_once('.') {
16            format!("{}.{}", add_commas(int_part), dec_part)
17        } else {
18            add_commas(s)
19        }
20    }
21}
22
23/// Format as percentage (e.g., 0.5 → "50%").
24pub fn label_percent(v: f64) -> String {
25    let pct = v * 100.0;
26    if (pct - pct.round()).abs() < 1e-10 {
27        format!("{}%", pct.round() as i64)
28    } else {
29        format!("{:.1}%", pct)
30    }
31}
32
33/// Format as US dollar with thousands separators (e.g., 1234 → "$1,234",
34/// -500 → "-$500"). Equivalent to `label_currency("$", "", ",", None)`.
35pub fn label_dollar(v: f64) -> String {
36    currency(v, "$", "", ",", None)
37}
38
39/// Sign-aware currency formatter (R's `scales::label_currency`): the minus sign
40/// goes *before* the prefix ("-€5", not "€-5"), thousands are grouped with
41/// `big_mark` (e.g. `","`, `"."`, `" "`, `"'"`; `""` disables grouping), and
42/// `accuracy` rounds to a multiple of it (`None` = up to two decimals, trailing
43/// zeros trimmed). The decimal mark is always `.`.
44///
45/// ```
46/// use ggplot_rs::scale::format::label_currency;
47/// let eur = label_currency("€", "", ".", Some(1.0));
48/// assert_eq!(eur(-1234567.0), "-€1.234.567");
49/// let chf = label_currency("", " CHF", "'", Some(0.01));
50/// assert_eq!(chf(9876.5), "9'876.50 CHF");
51/// ```
52pub fn label_currency(
53    prefix: &str,
54    suffix: &str,
55    big_mark: &str,
56    accuracy: Option<f64>,
57) -> impl Fn(f64) -> String + Send + Sync {
58    let prefix = prefix.to_string();
59    let suffix = suffix.to_string();
60    let big_mark = big_mark.to_string();
61    move |v: f64| currency(v, &prefix, &suffix, &big_mark, accuracy)
62}
63
64/// Shared body of [`label_currency`] / [`label_dollar`].
65fn currency(v: f64, prefix: &str, suffix: &str, big_mark: &str, accuracy: Option<f64>) -> String {
66    // Format the magnitude, then put the sign in front of the prefix. NaN and
67    // ±inf pass through as "NaN" / "inf".
68    let (_, body) = split_sign(format_accuracy(v.abs(), accuracy));
69    let sign = if v < 0.0 && !is_zero_number(&body) {
70        "-"
71    } else {
72        ""
73    };
74    let body = if big_mark == "," {
75        body
76    } else {
77        body.replace(',', big_mark)
78    };
79    format!("{sign}{prefix}{body}{suffix}")
80}
81
82/// Split a leading `-` off a formatted number.
83fn split_sign(s: String) -> (&'static str, String) {
84    match s.strip_prefix('-') {
85        Some(rest) => ("-", rest.to_string()),
86        None => ("", s),
87    }
88}
89
90/// True when a formatted number is (rounded to) zero, e.g. "0", "0.00".
91fn is_zero_number(s: &str) -> bool {
92    s.chars().all(|c| matches!(c, '0' | '.' | ','))
93}
94
95/// Format in scientific notation (e.g., 12345 → "1.23e4").
96pub fn label_scientific(v: f64) -> String {
97    if v == 0.0 {
98        return "0".to_string();
99    }
100    let exp = v.abs().log10().floor() as i32;
101    let mantissa = v / 10f64.powi(exp);
102    if (mantissa - mantissa.round()).abs() < 1e-10 {
103        format!("{}e{}", mantissa.round() as i64, exp)
104    } else {
105        let s = format!("{:.2}e{}", mantissa, exp);
106        // Trim trailing zeros in mantissa
107        if let Some((m, e)) = s.split_once('e') {
108            let m = m.trim_end_matches('0').trim_end_matches('.');
109            format!("{m}e{e}")
110        } else {
111            s
112        }
113    }
114}
115
116fn add_commas(s: &str) -> String {
117    let negative = s.starts_with('-');
118    let digits = if negative { &s[1..] } else { s };
119    let mut result = String::new();
120    for (i, ch) in digits.chars().rev().enumerate() {
121        if i > 0 && i % 3 == 0 {
122            result.push(',');
123        }
124        result.push(ch);
125    }
126    let formatted: String = result.chars().rev().collect();
127    if negative {
128        format!("-{formatted}")
129    } else {
130        formatted
131    }
132}
133
134/// A label formatter — any `Fn(f64) -> String`. The plain `label_*` functions
135/// coerce into this, and the configurable `label_number`/`label_si`/… builders
136/// return one directly.
137pub type LabelFormatter = Arc<dyn Fn(f64) -> String + Send + Sync>;
138
139/// A [`LabelFormatter`] stored in a scale's own `value_formatter` field
140/// (#164), wrapped so the scale keeps `UnwindSafe`/`RefUnwindSafe`: a
141/// `dyn Fn` trait object carries no auto-trait bounds of its own (only
142/// `Send`/`Sync`, which are explicit in `LabelFormatter`'s definition, not
143/// auto-derived), so storing a bare `Arc<dyn Fn...>` field would silently
144/// drop both from the containing struct — `cargo-semver-checks` flags
145/// that as a major-version break, which a patch release must not cause.
146/// The formatter closures this crate accepts (`Fn(f64) -> String + Send +
147/// Sync + 'static`) are a plain value-formatting callback never relied on
148/// to be unwind-safe by any caller, so asserting it here — the standard
149/// pattern for a stored callback field like this — changes no behaviour.
150#[derive(Clone)]
151pub(crate) struct UnwindSafeFormatter(pub(crate) LabelFormatter);
152
153impl std::panic::UnwindSafe for UnwindSafeFormatter {}
154impl std::panic::RefUnwindSafe for UnwindSafeFormatter {}
155
156impl UnwindSafeFormatter {
157    pub(crate) fn call(&self, v: f64) -> String {
158        (self.0)(v)
159    }
160}
161
162/// Round `v` to a multiple of `accuracy` and format with the implied decimals.
163fn format_accuracy(v: f64, accuracy: Option<f64>) -> String {
164    match accuracy {
165        Some(acc) if acc > 0.0 => {
166            let rounded = (v / acc).round() * acc;
167            let decimals = (-acc.log10().floor()).max(0.0) as usize;
168            let s = format!("{rounded:.decimals$}");
169            // Group only the integer part ("1234.50" → "1,234.50").
170            match s.split_once('.') {
171                Some((int_part, dec_part)) => format!("{}.{dec_part}", add_commas(int_part)),
172                None => add_commas(&s),
173            }
174        }
175        _ => label_comma(v),
176    }
177}
178
179/// General configurable number formatter (R's `scales::label_number`).
180/// Multiplies by `scale`, rounds to `accuracy` (None = trim), groups thousands
181/// with `,`, and wraps in `prefix`/`suffix` — with the minus sign placed before
182/// the prefix (`-€5`). For a custom thousands mark use [`label_currency`].
183pub fn label_number(
184    accuracy: Option<f64>,
185    prefix: &str,
186    suffix: &str,
187    scale: f64,
188) -> impl Fn(f64) -> String + Send + Sync {
189    let prefix = prefix.to_string();
190    let suffix = suffix.to_string();
191    move |v: f64| {
192        // Sign-aware: "-€5", never "€-5"; a value that rounds to zero loses
193        // its sign ("€0", not "-€0").
194        let (sign, body) = split_sign(format_accuracy(v * scale, accuracy));
195        let sign = if is_zero_number(&body) { "" } else { sign };
196        format!("{sign}{prefix}{body}{suffix}")
197    }
198}
199
200// ─── Locale-aware variants ─────────────────────────────────────────────
201//
202// Each `*_marks` formatter takes an explicit decimal mark and thousands mark;
203// each `*_locale` formatter takes them (plus the percent / currency
204// conventions) from a [`Locale`]. With `"."` / `","` they reproduce the plain
205// formatters exactly.
206
207use crate::locale::{localize_number, Locale};
208
209/// Shared body of the `label_number*` formatters: scale, round to `accuracy`,
210/// group, swap marks, then wrap in `prefix`/`suffix` with the sign in front.
211#[allow(clippy::too_many_arguments)]
212fn number_body(
213    v: f64,
214    accuracy: Option<f64>,
215    prefix: &str,
216    suffix: &str,
217    scale: f64,
218    decimal_mark: &str,
219    big_mark: &str,
220    min_grouping_digits: usize,
221) -> String {
222    let (sign, body) = split_sign(format_accuracy(v * scale, accuracy));
223    let sign = if is_zero_number(&body) { "" } else { sign };
224    let body = localize_number(&body, decimal_mark, big_mark, min_grouping_digits);
225    format!("{sign}{prefix}{body}{suffix}")
226}
227
228/// [`label_number`] with an explicit decimal mark and thousands mark
229/// (`""` disables grouping).
230///
231/// ```
232/// use ggplot_rs::scale::format::label_number_marks;
233/// let f = label_number_marks(Some(0.01), "", " kg", 1.0, ",", ".");
234/// assert_eq!(f(-1234.5), "-1.234,50 kg");
235/// ```
236pub fn label_number_marks(
237    accuracy: Option<f64>,
238    prefix: &str,
239    suffix: &str,
240    scale: f64,
241    decimal_mark: &str,
242    big_mark: &str,
243) -> impl Fn(f64) -> String + Send + Sync {
244    let prefix = prefix.to_string();
245    let suffix = suffix.to_string();
246    let dm = decimal_mark.to_string();
247    let bm = big_mark.to_string();
248    move |v: f64| number_body(v, accuracy, &prefix, &suffix, scale, &dm, &bm, 1)
249}
250
251/// [`label_number`] (scale 1) with the marks of `locale`.
252///
253/// ```
254/// use ggplot_rs::locale::Locale;
255/// use ggplot_rs::scale::format::label_number_locale;
256/// let f = label_number_locale(&Locale::de_de(), Some(0.1), "", " kg");
257/// assert_eq!(f(1234.56), "1.234,6 kg");
258/// ```
259pub fn label_number_locale(
260    locale: &Locale,
261    accuracy: Option<f64>,
262    prefix: &str,
263    suffix: &str,
264) -> impl Fn(f64) -> String + Send + Sync {
265    let prefix = prefix.to_string();
266    let suffix = suffix.to_string();
267    let loc = locale.clone();
268    move |v: f64| {
269        number_body(
270            v,
271            accuracy,
272            &prefix,
273            &suffix,
274            1.0,
275            &loc.decimal_mark,
276            &loc.big_mark,
277            loc.min_grouping_digits,
278        )
279    }
280}
281
282/// [`label_comma`] with explicit marks: `label_comma_marks(",", ".")(1234567.5)`
283/// is `"1.234.567,5"`.
284pub fn label_comma_marks(
285    decimal_mark: &str,
286    big_mark: &str,
287) -> impl Fn(f64) -> String + Send + Sync {
288    let dm = decimal_mark.to_string();
289    let bm = big_mark.to_string();
290    move |v: f64| localize_number(&label_comma(v), &dm, &bm, 1)
291}
292
293/// [`label_comma`] with the marks of `locale` (de-DE: `1.234.567`).
294pub fn label_comma_locale(locale: &Locale) -> impl Fn(f64) -> String + Send + Sync {
295    let loc = locale.clone();
296    move |v: f64| loc.localize_number(&label_comma(v))
297}
298
299/// Shared body of the percent formatters. `accuracy` (in percentage points,
300/// e.g. `0.1`) rounds; `None` behaves like [`label_percent`] (whole numbers
301/// verbatim, otherwise one decimal).
302fn percent_body(
303    v: f64,
304    accuracy: Option<f64>,
305    decimal_mark: &str,
306    big_mark: &str,
307    min_grouping_digits: usize,
308    suffix: &str,
309) -> String {
310    match accuracy {
311        Some(_) => number_body(
312            v,
313            accuracy,
314            "",
315            suffix,
316            100.0,
317            decimal_mark,
318            big_mark,
319            min_grouping_digits,
320        ),
321        None => {
322            let pct = v * 100.0;
323            let body = if (pct - pct.round()).abs() < 1e-10 {
324                format!("{}", pct.round() as i64)
325            } else {
326                format!("{pct:.1}")
327            };
328            let body = localize_number(&body, decimal_mark, big_mark, min_grouping_digits);
329            format!("{body}{suffix}")
330        }
331    }
332}
333
334/// [`label_percent`] with explicit marks and percent suffix (`"%"`, `" %"`).
335/// `accuracy` is in percentage points (`Some(0.1)` → one decimal); `None`
336/// matches [`label_percent`].
337///
338/// ```
339/// use ggplot_rs::scale::format::label_percent_marks;
340/// let f = label_percent_marks(None, ",", ".", " %");
341/// assert_eq!(f(0.123), "12,3 %");
342/// ```
343pub fn label_percent_marks(
344    accuracy: Option<f64>,
345    decimal_mark: &str,
346    big_mark: &str,
347    suffix: &str,
348) -> impl Fn(f64) -> String + Send + Sync {
349    let dm = decimal_mark.to_string();
350    let bm = big_mark.to_string();
351    let suffix = suffix.to_string();
352    move |v: f64| percent_body(v, accuracy, &dm, &bm, 1, &suffix)
353}
354
355/// [`label_percent`] in the conventions of `locale`: de-DE / fr-FR `"12,3 %"`,
356/// en-US / en-GB `"12.3%"`. `accuracy` as in [`label_percent_marks`].
357pub fn label_percent_locale(
358    locale: &Locale,
359    accuracy: Option<f64>,
360) -> impl Fn(f64) -> String + Send + Sync {
361    let loc = locale.clone();
362    move |v: f64| {
363        percent_body(
364            v,
365            accuracy,
366            &loc.decimal_mark,
367            &loc.big_mark,
368            loc.min_grouping_digits,
369            &loc.percent_suffix,
370        )
371    }
372}
373
374/// [`label_currency`] with an explicit decimal mark as well.
375///
376/// ```
377/// use ggplot_rs::scale::format::label_currency_marks;
378/// let eur = label_currency_marks("", " €", ".", ",", Some(0.01));
379/// assert_eq!(eur(-1234.5), "-1.234,50 €");
380/// ```
381pub fn label_currency_marks(
382    prefix: &str,
383    suffix: &str,
384    big_mark: &str,
385    decimal_mark: &str,
386    accuracy: Option<f64>,
387) -> impl Fn(f64) -> String + Send + Sync {
388    let prefix = prefix.to_string();
389    let suffix = suffix.to_string();
390    let bm = big_mark.to_string();
391    let dm = decimal_mark.to_string();
392    move |v: f64| currency_marks(v, &prefix, &suffix, &bm, &dm, 1, accuracy)
393}
394
395/// Currency in the conventions of `locale` — its symbol, placement and marks:
396/// de-DE `"1.234,50 €"`, en-US `"$1,234.50"`, en-GB `"£1,234.50"`, fr-FR
397/// `"1 234,50 €"`. Use [`Locale::with_currency`] for another symbol.
398pub fn label_currency_locale(
399    locale: &Locale,
400    accuracy: Option<f64>,
401) -> impl Fn(f64) -> String + Send + Sync {
402    let (prefix, suffix) = locale.currency_affixes(&locale.currency_symbol);
403    let loc = locale.clone();
404    move |v: f64| {
405        currency_marks(
406            v,
407            &prefix,
408            &suffix,
409            &loc.big_mark,
410            &loc.decimal_mark,
411            loc.min_grouping_digits,
412            accuracy,
413        )
414    }
415}
416
417/// Sign-aware currency body with both marks (see [`currency`]).
418fn currency_marks(
419    v: f64,
420    prefix: &str,
421    suffix: &str,
422    big_mark: &str,
423    decimal_mark: &str,
424    min_grouping_digits: usize,
425    accuracy: Option<f64>,
426) -> String {
427    let (_, body) = split_sign(format_accuracy(v.abs(), accuracy));
428    let sign = if v < 0.0 && !is_zero_number(&body) {
429        "-"
430    } else {
431        ""
432    };
433    let body = localize_number(&body, decimal_mark, big_mark, min_grouping_digits);
434    format!("{sign}{prefix}{body}{suffix}")
435}
436
437/// SI-prefixed number formatter: 1_500 → "1.5k", 2.3e6 → "2.3M", 5e-4 → "500µ".
438pub fn label_si() -> impl Fn(f64) -> String + Send + Sync {
439    |v: f64| {
440        if v == 0.0 {
441            return "0".to_string();
442        }
443        let a = v.abs();
444        let (div, suffix) = if a >= 1e12 {
445            (1e12, "T")
446        } else if a >= 1e9 {
447            (1e9, "G")
448        } else if a >= 1e6 {
449            (1e6, "M")
450        } else if a >= 1e3 {
451            (1e3, "k")
452        } else if a >= 1.0 {
453            (1.0, "")
454        } else if a >= 1e-3 {
455            (1e-3, "m")
456        } else if a >= 1e-6 {
457            (1e-6, "µ")
458        } else {
459            (1e-9, "n")
460        };
461        let scaled = v / div;
462        let s = format!("{scaled:.1}");
463        let s = s.trim_end_matches('0').trim_end_matches('.');
464        format!("{s}{suffix}")
465    }
466}
467
468/// Ordinal formatter: 1 → "1st", 2 → "2nd", 3 → "3rd", 11 → "11th".
469pub fn label_ordinal() -> impl Fn(f64) -> String + Send + Sync {
470    |v: f64| {
471        let n = v.round() as i64;
472        let suffix = match (n.rem_euclid(10), n.rem_euclid(100)) {
473            (1, r) if r != 11 => "st",
474            (2, r) if r != 12 => "nd",
475            (3, r) if r != 13 => "rd",
476            _ => "th",
477        };
478        format!("{n}{suffix}")
479    }
480}
481
482/// Byte-size formatter. `binary = true` uses 1024-based KiB/MiB; otherwise
483/// 1000-based kB/MB.
484pub fn label_bytes(binary: bool) -> impl Fn(f64) -> String + Send + Sync {
485    let (base, units): (f64, &[&str]) = if binary {
486        (1024.0, &["B", "KiB", "MiB", "GiB", "TiB"])
487    } else {
488        (1000.0, &["B", "kB", "MB", "GB", "TB"])
489    };
490    move |v: f64| {
491        let a = v.abs();
492        if a < base {
493            return format!("{} {}", v.round() as i64, units[0]);
494        }
495        let mut val = a;
496        let mut i = 0;
497        while val >= base && i < units.len() - 1 {
498            val /= base;
499            i += 1;
500        }
501        let s = format!("{val:.1}");
502        let s = s.trim_end_matches('0').trim_end_matches('.');
503        let sign = if v < 0.0 { "-" } else { "" };
504        format!("{sign}{s} {}", units[i])
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn test_label_comma() {
514        assert_eq!(label_comma(1000.0), "1,000");
515        assert_eq!(label_comma(1234567.0), "1,234,567");
516        assert_eq!(label_comma(42.0), "42");
517        assert_eq!(label_comma(-5000.0), "-5,000");
518    }
519
520    #[test]
521    fn test_label_percent() {
522        assert_eq!(label_percent(0.5), "50%");
523        assert_eq!(label_percent(0.0), "0%");
524        assert_eq!(label_percent(1.0), "100%");
525        assert_eq!(label_percent(0.123), "12.3%");
526    }
527
528    #[test]
529    fn test_label_dollar() {
530        assert_eq!(label_dollar(1000.0), "$1,000");
531        assert_eq!(label_dollar(0.0), "$0");
532        assert_eq!(label_dollar(-500.0), "-$500");
533    }
534
535    #[test]
536    fn test_label_scientific() {
537        assert_eq!(label_scientific(12345.0), "1.23e4");
538        assert_eq!(label_scientific(0.0), "0");
539        assert_eq!(label_scientific(100.0), "1e2");
540    }
541
542    #[test]
543    fn test_label_si() {
544        let f = label_si();
545        assert_eq!(f(1500.0), "1.5k");
546        assert_eq!(f(2_300_000.0), "2.3M");
547        assert_eq!(f(5e9), "5G");
548        assert_eq!(f(0.0), "0");
549        assert_eq!(f(0.0005), "500µ");
550        assert_eq!(f(-4000.0), "-4k");
551    }
552
553    #[test]
554    fn test_label_number() {
555        let f = label_number(Some(0.1), "", " kg", 1.0);
556        assert_eq!(f(4.16), "4.2 kg");
557        let pct = label_number(Some(1.0), "", "%", 100.0);
558        assert_eq!(pct(0.25), "25%");
559        let money = label_number(None, "€", "", 1.0);
560        assert_eq!(money(1500.0), "€1,500");
561    }
562
563    #[test]
564    fn label_number_is_sign_aware() {
565        let eur = label_number(None, "€", "", 1.0);
566        assert_eq!(eur(-5.0), "-€5");
567        assert_eq!(eur(-1234.5), "-€1,234.5");
568        let f = label_number(Some(1.0), "€", "", 1.0);
569        assert_eq!(f(-0.2), "€0", "rounds to zero: no sign");
570        assert_eq!(f(1_234_567.0), "€1,234,567");
571        // Thousands grouping never touches the decimals.
572        let g = label_number(Some(0.01), "", "", 1.0);
573        assert_eq!(g(1234.5), "1,234.50");
574        assert_eq!(g(-98765.4321), "-98,765.43");
575    }
576
577    #[test]
578    fn label_currency_marks_and_signs() {
579        let eur = label_currency("€", "", ".", Some(1.0));
580        assert_eq!(eur(-1_234_567.0), "-€1.234.567");
581        assert_eq!(eur(0.0), "€0");
582        let chf = label_currency("", " CHF", "'", Some(0.01));
583        assert_eq!(chf(9876.5), "9'876.50 CHF");
584        assert_eq!(chf(-0.001), "0.00 CHF");
585        let plain = label_currency("$", "", "", None);
586        assert_eq!(plain(1234.5), "$1234.5");
587        let usd = label_currency("$", "", ",", None);
588        for v in [0.0, 1.0, -500.0, 1234.5, -1_000_000.0, 0.125] {
589            assert_eq!(usd(v), label_dollar(v), "label_dollar parity at {v}");
590        }
591        assert_eq!(label_dollar(-1234.0), "-$1,234");
592        assert_eq!(usd(f64::NAN), "$NaN");
593        assert_eq!(usd(f64::NEG_INFINITY), "-$inf");
594    }
595
596    #[test]
597    fn marks_variants_match_plain_formatters_with_default_marks() {
598        let num = label_number_marks(Some(0.01), "€", "", 1.0, ".", ",");
599        let plain = label_number(Some(0.01), "€", "", 1.0);
600        let comma = label_comma_marks(".", ",");
601        let pct = label_percent_marks(None, ".", "", "%"); // label_percent never groups
602        let cur = label_currency_marks("$", "", ",", ".", None);
603        let us = Locale::en_us();
604        let pct_us = label_percent_locale(&us, None);
605        let comma_us = label_comma_locale(&us);
606        for v in [
607            0.0, 1.0, -1.0, 0.125, -0.004, 999.0, 1000.0, -1234.5, 98765.4321, 1e9, -2.5e12,
608        ] {
609            assert_eq!(num(v), plain(v), "label_number at {v}");
610            assert_eq!(comma(v), label_comma(v), "label_comma at {v}");
611            assert_eq!(comma_us(v), label_comma(v), "label_comma en-US at {v}");
612            assert_eq!(pct(v), label_percent(v), "label_percent at {v}");
613            assert_eq!(cur(v), label_dollar(v), "label_dollar at {v}");
614            if v.abs() < 10.0 {
615                // label_percent never groups; en-US groups from 1,000 %.
616                assert_eq!(pct_us(v), label_percent(v), "en-US percent at {v}");
617            }
618        }
619    }
620
621    #[test]
622    fn currency_per_locale() {
623        let acc = Some(0.01);
624        assert_eq!(
625            label_currency_locale(&Locale::de_de(), acc)(1234.5),
626            "1.234,50 €"
627        );
628        assert_eq!(
629            label_currency_locale(&Locale::en_us(), acc)(1234.5),
630            "$1,234.50"
631        );
632        assert_eq!(
633            label_currency_locale(&Locale::en_gb(), acc)(1234.5),
634            "£1,234.50"
635        );
636        assert_eq!(
637            label_currency_locale(&Locale::fr_fr(), acc)(1234.5),
638            "1 234,50 €"
639        );
640        // Negative: sign before the whole amount, also before a prefix.
641        assert_eq!(
642            label_currency_locale(&Locale::de_de(), acc)(-1234.5),
643            "-1.234,50 €"
644        );
645        assert_eq!(
646            label_currency_locale(&Locale::en_us(), acc)(-1234.5),
647            "-$1,234.50"
648        );
649        // Rounds to zero → no sign.
650        assert_eq!(
651            label_currency_locale(&Locale::de_de(), acc)(-0.001),
652            "0,00 €"
653        );
654        // Rounding and large numbers.
655        let de0 = label_currency_locale(&Locale::de_de(), Some(1.0));
656        assert_eq!(de0(1_234_567.5), "1.234.568 €");
657        assert_eq!(de0(999.4), "999 €");
658        let de_trim = label_currency_locale(&Locale::de_de(), None);
659        assert_eq!(de_trim(1234.5), "1.234,5 €");
660        assert_eq!(de_trim(2_000_000.0), "2.000.000 €");
661        let chf = label_currency_locale(&Locale::de_de().with_currency("CHF"), acc);
662        assert_eq!(chf(1234.5), "1.234,50 CHF");
663    }
664
665    #[test]
666    fn numbers_per_locale() {
667        let cases: [(Locale, [&str; 5]); 4] = [
668            (
669                Locale::en_us(),
670                ["1,234.57", "-1,234.57", "0.10", "1,234,567,890.00", "0.00"],
671            ),
672            (
673                Locale::en_gb(),
674                ["1,234.57", "-1,234.57", "0.10", "1,234,567,890.00", "0.00"],
675            ),
676            (
677                Locale::de_de(),
678                ["1.234,57", "-1.234,57", "0,10", "1.234.567.890,00", "0,00"],
679            ),
680            (
681                Locale::fr_fr(),
682                ["1 234,57", "-1 234,57", "0,10", "1 234 567 890,00", "0,00"],
683            ),
684        ];
685        for (loc, want) in cases {
686            let f = label_number_locale(&loc, Some(0.01), "", "");
687            let got = [
688                f(1234.567),
689                f(-1234.567),
690                f(0.1),
691                f(1_234_567_890.0),
692                f(-0.004),
693            ];
694            assert_eq!(got.map(|s| s), want.map(String::from), "{}", loc.tag);
695        }
696        let de = Locale::de_de();
697        assert_eq!(label_comma_locale(&de)(-1_234_567.0), "-1.234.567");
698        assert_eq!(label_comma_locale(&de)(1234.567), "1.234,57");
699        assert_eq!(label_comma_locale(&Locale::fr_fr())(1234.5), "1 234,5");
700        let pre = label_number_locale(&de, None, "€", "");
701        assert_eq!(pre(-5.0), "-€5");
702    }
703
704    #[test]
705    fn percent_per_locale() {
706        let de = label_percent_locale(&Locale::de_de(), None);
707        assert_eq!(de(0.5), "50 %");
708        assert_eq!(de(0.123), "12,3 %");
709        assert_eq!(de(-0.25), "-25 %");
710        assert_eq!(de(12.345), "1.234,5 %");
711        let fr = label_percent_locale(&Locale::fr_fr(), Some(0.01));
712        assert_eq!(fr(0.12345), "12,35 %");
713        let us = label_percent_locale(&Locale::en_us(), None);
714        assert_eq!(us(0.123), "12.3%");
715        assert_eq!(
716            label_percent_locale(&Locale::en_gb(), Some(1.0))(0.506),
717            "51%"
718        );
719    }
720
721    #[test]
722    fn test_label_ordinal() {
723        let f = label_ordinal();
724        assert_eq!(f(1.0), "1st");
725        assert_eq!(f(2.0), "2nd");
726        assert_eq!(f(3.0), "3rd");
727        assert_eq!(f(4.0), "4th");
728        assert_eq!(f(11.0), "11th");
729        assert_eq!(f(22.0), "22nd");
730    }
731
732    #[test]
733    fn test_label_bytes() {
734        let f = label_bytes(false);
735        assert_eq!(f(500.0), "500 B");
736        assert_eq!(f(1500.0), "1.5 kB");
737        assert_eq!(f(2_000_000.0), "2 MB");
738        let b = label_bytes(true);
739        assert_eq!(b(1024.0), "1 KiB");
740    }
741}