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/// Round `v` to a multiple of `accuracy` and format with the implied decimals.
140fn format_accuracy(v: f64, accuracy: Option<f64>) -> String {
141    match accuracy {
142        Some(acc) if acc > 0.0 => {
143            let rounded = (v / acc).round() * acc;
144            let decimals = (-acc.log10().floor()).max(0.0) as usize;
145            let s = format!("{rounded:.decimals$}");
146            // Group only the integer part ("1234.50" → "1,234.50").
147            match s.split_once('.') {
148                Some((int_part, dec_part)) => format!("{}.{dec_part}", add_commas(int_part)),
149                None => add_commas(&s),
150            }
151        }
152        _ => label_comma(v),
153    }
154}
155
156/// General configurable number formatter (R's `scales::label_number`).
157/// Multiplies by `scale`, rounds to `accuracy` (None = trim), groups thousands
158/// with `,`, and wraps in `prefix`/`suffix` — with the minus sign placed before
159/// the prefix (`-€5`). For a custom thousands mark use [`label_currency`].
160pub fn label_number(
161    accuracy: Option<f64>,
162    prefix: &str,
163    suffix: &str,
164    scale: f64,
165) -> impl Fn(f64) -> String + Send + Sync {
166    let prefix = prefix.to_string();
167    let suffix = suffix.to_string();
168    move |v: f64| {
169        // Sign-aware: "-€5", never "€-5"; a value that rounds to zero loses
170        // its sign ("€0", not "-€0").
171        let (sign, body) = split_sign(format_accuracy(v * scale, accuracy));
172        let sign = if is_zero_number(&body) { "" } else { sign };
173        format!("{sign}{prefix}{body}{suffix}")
174    }
175}
176
177// ─── Locale-aware variants ─────────────────────────────────────────────
178//
179// Each `*_marks` formatter takes an explicit decimal mark and thousands mark;
180// each `*_locale` formatter takes them (plus the percent / currency
181// conventions) from a [`Locale`]. With `"."` / `","` they reproduce the plain
182// formatters exactly.
183
184use crate::locale::{localize_number, Locale};
185
186/// Shared body of the `label_number*` formatters: scale, round to `accuracy`,
187/// group, swap marks, then wrap in `prefix`/`suffix` with the sign in front.
188#[allow(clippy::too_many_arguments)]
189fn number_body(
190    v: f64,
191    accuracy: Option<f64>,
192    prefix: &str,
193    suffix: &str,
194    scale: f64,
195    decimal_mark: &str,
196    big_mark: &str,
197    min_grouping_digits: usize,
198) -> String {
199    let (sign, body) = split_sign(format_accuracy(v * scale, accuracy));
200    let sign = if is_zero_number(&body) { "" } else { sign };
201    let body = localize_number(&body, decimal_mark, big_mark, min_grouping_digits);
202    format!("{sign}{prefix}{body}{suffix}")
203}
204
205/// [`label_number`] with an explicit decimal mark and thousands mark
206/// (`""` disables grouping).
207///
208/// ```
209/// use ggplot_rs::scale::format::label_number_marks;
210/// let f = label_number_marks(Some(0.01), "", " kg", 1.0, ",", ".");
211/// assert_eq!(f(-1234.5), "-1.234,50 kg");
212/// ```
213pub fn label_number_marks(
214    accuracy: Option<f64>,
215    prefix: &str,
216    suffix: &str,
217    scale: f64,
218    decimal_mark: &str,
219    big_mark: &str,
220) -> impl Fn(f64) -> String + Send + Sync {
221    let prefix = prefix.to_string();
222    let suffix = suffix.to_string();
223    let dm = decimal_mark.to_string();
224    let bm = big_mark.to_string();
225    move |v: f64| number_body(v, accuracy, &prefix, &suffix, scale, &dm, &bm, 1)
226}
227
228/// [`label_number`] (scale 1) with the marks of `locale`.
229///
230/// ```
231/// use ggplot_rs::locale::Locale;
232/// use ggplot_rs::scale::format::label_number_locale;
233/// let f = label_number_locale(&Locale::de_de(), Some(0.1), "", " kg");
234/// assert_eq!(f(1234.56), "1.234,6 kg");
235/// ```
236pub fn label_number_locale(
237    locale: &Locale,
238    accuracy: Option<f64>,
239    prefix: &str,
240    suffix: &str,
241) -> impl Fn(f64) -> String + Send + Sync {
242    let prefix = prefix.to_string();
243    let suffix = suffix.to_string();
244    let loc = locale.clone();
245    move |v: f64| {
246        number_body(
247            v,
248            accuracy,
249            &prefix,
250            &suffix,
251            1.0,
252            &loc.decimal_mark,
253            &loc.big_mark,
254            loc.min_grouping_digits,
255        )
256    }
257}
258
259/// [`label_comma`] with explicit marks: `label_comma_marks(",", ".")(1234567.5)`
260/// is `"1.234.567,5"`.
261pub fn label_comma_marks(
262    decimal_mark: &str,
263    big_mark: &str,
264) -> impl Fn(f64) -> String + Send + Sync {
265    let dm = decimal_mark.to_string();
266    let bm = big_mark.to_string();
267    move |v: f64| localize_number(&label_comma(v), &dm, &bm, 1)
268}
269
270/// [`label_comma`] with the marks of `locale` (de-DE: `1.234.567`).
271pub fn label_comma_locale(locale: &Locale) -> impl Fn(f64) -> String + Send + Sync {
272    let loc = locale.clone();
273    move |v: f64| loc.localize_number(&label_comma(v))
274}
275
276/// Shared body of the percent formatters. `accuracy` (in percentage points,
277/// e.g. `0.1`) rounds; `None` behaves like [`label_percent`] (whole numbers
278/// verbatim, otherwise one decimal).
279fn percent_body(
280    v: f64,
281    accuracy: Option<f64>,
282    decimal_mark: &str,
283    big_mark: &str,
284    min_grouping_digits: usize,
285    suffix: &str,
286) -> String {
287    match accuracy {
288        Some(_) => number_body(
289            v,
290            accuracy,
291            "",
292            suffix,
293            100.0,
294            decimal_mark,
295            big_mark,
296            min_grouping_digits,
297        ),
298        None => {
299            let pct = v * 100.0;
300            let body = if (pct - pct.round()).abs() < 1e-10 {
301                format!("{}", pct.round() as i64)
302            } else {
303                format!("{pct:.1}")
304            };
305            let body = localize_number(&body, decimal_mark, big_mark, min_grouping_digits);
306            format!("{body}{suffix}")
307        }
308    }
309}
310
311/// [`label_percent`] with explicit marks and percent suffix (`"%"`, `" %"`).
312/// `accuracy` is in percentage points (`Some(0.1)` → one decimal); `None`
313/// matches [`label_percent`].
314///
315/// ```
316/// use ggplot_rs::scale::format::label_percent_marks;
317/// let f = label_percent_marks(None, ",", ".", " %");
318/// assert_eq!(f(0.123), "12,3 %");
319/// ```
320pub fn label_percent_marks(
321    accuracy: Option<f64>,
322    decimal_mark: &str,
323    big_mark: &str,
324    suffix: &str,
325) -> impl Fn(f64) -> String + Send + Sync {
326    let dm = decimal_mark.to_string();
327    let bm = big_mark.to_string();
328    let suffix = suffix.to_string();
329    move |v: f64| percent_body(v, accuracy, &dm, &bm, 1, &suffix)
330}
331
332/// [`label_percent`] in the conventions of `locale`: de-DE / fr-FR `"12,3 %"`,
333/// en-US / en-GB `"12.3%"`. `accuracy` as in [`label_percent_marks`].
334pub fn label_percent_locale(
335    locale: &Locale,
336    accuracy: Option<f64>,
337) -> impl Fn(f64) -> String + Send + Sync {
338    let loc = locale.clone();
339    move |v: f64| {
340        percent_body(
341            v,
342            accuracy,
343            &loc.decimal_mark,
344            &loc.big_mark,
345            loc.min_grouping_digits,
346            &loc.percent_suffix,
347        )
348    }
349}
350
351/// [`label_currency`] with an explicit decimal mark as well.
352///
353/// ```
354/// use ggplot_rs::scale::format::label_currency_marks;
355/// let eur = label_currency_marks("", " €", ".", ",", Some(0.01));
356/// assert_eq!(eur(-1234.5), "-1.234,50 €");
357/// ```
358pub fn label_currency_marks(
359    prefix: &str,
360    suffix: &str,
361    big_mark: &str,
362    decimal_mark: &str,
363    accuracy: Option<f64>,
364) -> impl Fn(f64) -> String + Send + Sync {
365    let prefix = prefix.to_string();
366    let suffix = suffix.to_string();
367    let bm = big_mark.to_string();
368    let dm = decimal_mark.to_string();
369    move |v: f64| currency_marks(v, &prefix, &suffix, &bm, &dm, 1, accuracy)
370}
371
372/// Currency in the conventions of `locale` — its symbol, placement and marks:
373/// de-DE `"1.234,50 €"`, en-US `"$1,234.50"`, en-GB `"£1,234.50"`, fr-FR
374/// `"1 234,50 €"`. Use [`Locale::with_currency`] for another symbol.
375pub fn label_currency_locale(
376    locale: &Locale,
377    accuracy: Option<f64>,
378) -> impl Fn(f64) -> String + Send + Sync {
379    let (prefix, suffix) = locale.currency_affixes(&locale.currency_symbol);
380    let loc = locale.clone();
381    move |v: f64| {
382        currency_marks(
383            v,
384            &prefix,
385            &suffix,
386            &loc.big_mark,
387            &loc.decimal_mark,
388            loc.min_grouping_digits,
389            accuracy,
390        )
391    }
392}
393
394/// Sign-aware currency body with both marks (see [`currency`]).
395fn currency_marks(
396    v: f64,
397    prefix: &str,
398    suffix: &str,
399    big_mark: &str,
400    decimal_mark: &str,
401    min_grouping_digits: usize,
402    accuracy: Option<f64>,
403) -> String {
404    let (_, body) = split_sign(format_accuracy(v.abs(), accuracy));
405    let sign = if v < 0.0 && !is_zero_number(&body) {
406        "-"
407    } else {
408        ""
409    };
410    let body = localize_number(&body, decimal_mark, big_mark, min_grouping_digits);
411    format!("{sign}{prefix}{body}{suffix}")
412}
413
414/// SI-prefixed number formatter: 1_500 → "1.5k", 2.3e6 → "2.3M", 5e-4 → "500µ".
415pub fn label_si() -> impl Fn(f64) -> String + Send + Sync {
416    |v: f64| {
417        if v == 0.0 {
418            return "0".to_string();
419        }
420        let a = v.abs();
421        let (div, suffix) = if a >= 1e12 {
422            (1e12, "T")
423        } else if a >= 1e9 {
424            (1e9, "G")
425        } else if a >= 1e6 {
426            (1e6, "M")
427        } else if a >= 1e3 {
428            (1e3, "k")
429        } else if a >= 1.0 {
430            (1.0, "")
431        } else if a >= 1e-3 {
432            (1e-3, "m")
433        } else if a >= 1e-6 {
434            (1e-6, "µ")
435        } else {
436            (1e-9, "n")
437        };
438        let scaled = v / div;
439        let s = format!("{scaled:.1}");
440        let s = s.trim_end_matches('0').trim_end_matches('.');
441        format!("{s}{suffix}")
442    }
443}
444
445/// Ordinal formatter: 1 → "1st", 2 → "2nd", 3 → "3rd", 11 → "11th".
446pub fn label_ordinal() -> impl Fn(f64) -> String + Send + Sync {
447    |v: f64| {
448        let n = v.round() as i64;
449        let suffix = match (n.rem_euclid(10), n.rem_euclid(100)) {
450            (1, r) if r != 11 => "st",
451            (2, r) if r != 12 => "nd",
452            (3, r) if r != 13 => "rd",
453            _ => "th",
454        };
455        format!("{n}{suffix}")
456    }
457}
458
459/// Byte-size formatter. `binary = true` uses 1024-based KiB/MiB; otherwise
460/// 1000-based kB/MB.
461pub fn label_bytes(binary: bool) -> impl Fn(f64) -> String + Send + Sync {
462    let (base, units): (f64, &[&str]) = if binary {
463        (1024.0, &["B", "KiB", "MiB", "GiB", "TiB"])
464    } else {
465        (1000.0, &["B", "kB", "MB", "GB", "TB"])
466    };
467    move |v: f64| {
468        let a = v.abs();
469        if a < base {
470            return format!("{} {}", v.round() as i64, units[0]);
471        }
472        let mut val = a;
473        let mut i = 0;
474        while val >= base && i < units.len() - 1 {
475            val /= base;
476            i += 1;
477        }
478        let s = format!("{val:.1}");
479        let s = s.trim_end_matches('0').trim_end_matches('.');
480        let sign = if v < 0.0 { "-" } else { "" };
481        format!("{sign}{s} {}", units[i])
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_label_comma() {
491        assert_eq!(label_comma(1000.0), "1,000");
492        assert_eq!(label_comma(1234567.0), "1,234,567");
493        assert_eq!(label_comma(42.0), "42");
494        assert_eq!(label_comma(-5000.0), "-5,000");
495    }
496
497    #[test]
498    fn test_label_percent() {
499        assert_eq!(label_percent(0.5), "50%");
500        assert_eq!(label_percent(0.0), "0%");
501        assert_eq!(label_percent(1.0), "100%");
502        assert_eq!(label_percent(0.123), "12.3%");
503    }
504
505    #[test]
506    fn test_label_dollar() {
507        assert_eq!(label_dollar(1000.0), "$1,000");
508        assert_eq!(label_dollar(0.0), "$0");
509        assert_eq!(label_dollar(-500.0), "-$500");
510    }
511
512    #[test]
513    fn test_label_scientific() {
514        assert_eq!(label_scientific(12345.0), "1.23e4");
515        assert_eq!(label_scientific(0.0), "0");
516        assert_eq!(label_scientific(100.0), "1e2");
517    }
518
519    #[test]
520    fn test_label_si() {
521        let f = label_si();
522        assert_eq!(f(1500.0), "1.5k");
523        assert_eq!(f(2_300_000.0), "2.3M");
524        assert_eq!(f(5e9), "5G");
525        assert_eq!(f(0.0), "0");
526        assert_eq!(f(0.0005), "500µ");
527        assert_eq!(f(-4000.0), "-4k");
528    }
529
530    #[test]
531    fn test_label_number() {
532        let f = label_number(Some(0.1), "", " kg", 1.0);
533        assert_eq!(f(4.16), "4.2 kg");
534        let pct = label_number(Some(1.0), "", "%", 100.0);
535        assert_eq!(pct(0.25), "25%");
536        let money = label_number(None, "€", "", 1.0);
537        assert_eq!(money(1500.0), "€1,500");
538    }
539
540    #[test]
541    fn label_number_is_sign_aware() {
542        let eur = label_number(None, "€", "", 1.0);
543        assert_eq!(eur(-5.0), "-€5");
544        assert_eq!(eur(-1234.5), "-€1,234.5");
545        let f = label_number(Some(1.0), "€", "", 1.0);
546        assert_eq!(f(-0.2), "€0", "rounds to zero: no sign");
547        assert_eq!(f(1_234_567.0), "€1,234,567");
548        // Thousands grouping never touches the decimals.
549        let g = label_number(Some(0.01), "", "", 1.0);
550        assert_eq!(g(1234.5), "1,234.50");
551        assert_eq!(g(-98765.4321), "-98,765.43");
552    }
553
554    #[test]
555    fn label_currency_marks_and_signs() {
556        let eur = label_currency("€", "", ".", Some(1.0));
557        assert_eq!(eur(-1_234_567.0), "-€1.234.567");
558        assert_eq!(eur(0.0), "€0");
559        let chf = label_currency("", " CHF", "'", Some(0.01));
560        assert_eq!(chf(9876.5), "9'876.50 CHF");
561        assert_eq!(chf(-0.001), "0.00 CHF");
562        let plain = label_currency("$", "", "", None);
563        assert_eq!(plain(1234.5), "$1234.5");
564        let usd = label_currency("$", "", ",", None);
565        for v in [0.0, 1.0, -500.0, 1234.5, -1_000_000.0, 0.125] {
566            assert_eq!(usd(v), label_dollar(v), "label_dollar parity at {v}");
567        }
568        assert_eq!(label_dollar(-1234.0), "-$1,234");
569        assert_eq!(usd(f64::NAN), "$NaN");
570        assert_eq!(usd(f64::NEG_INFINITY), "-$inf");
571    }
572
573    #[test]
574    fn marks_variants_match_plain_formatters_with_default_marks() {
575        let num = label_number_marks(Some(0.01), "€", "", 1.0, ".", ",");
576        let plain = label_number(Some(0.01), "€", "", 1.0);
577        let comma = label_comma_marks(".", ",");
578        let pct = label_percent_marks(None, ".", "", "%"); // label_percent never groups
579        let cur = label_currency_marks("$", "", ",", ".", None);
580        let us = Locale::en_us();
581        let pct_us = label_percent_locale(&us, None);
582        let comma_us = label_comma_locale(&us);
583        for v in [
584            0.0, 1.0, -1.0, 0.125, -0.004, 999.0, 1000.0, -1234.5, 98765.4321, 1e9, -2.5e12,
585        ] {
586            assert_eq!(num(v), plain(v), "label_number at {v}");
587            assert_eq!(comma(v), label_comma(v), "label_comma at {v}");
588            assert_eq!(comma_us(v), label_comma(v), "label_comma en-US at {v}");
589            assert_eq!(pct(v), label_percent(v), "label_percent at {v}");
590            assert_eq!(cur(v), label_dollar(v), "label_dollar at {v}");
591            if v.abs() < 10.0 {
592                // label_percent never groups; en-US groups from 1,000 %.
593                assert_eq!(pct_us(v), label_percent(v), "en-US percent at {v}");
594            }
595        }
596    }
597
598    #[test]
599    fn currency_per_locale() {
600        let acc = Some(0.01);
601        assert_eq!(
602            label_currency_locale(&Locale::de_de(), acc)(1234.5),
603            "1.234,50 €"
604        );
605        assert_eq!(
606            label_currency_locale(&Locale::en_us(), acc)(1234.5),
607            "$1,234.50"
608        );
609        assert_eq!(
610            label_currency_locale(&Locale::en_gb(), acc)(1234.5),
611            "£1,234.50"
612        );
613        assert_eq!(
614            label_currency_locale(&Locale::fr_fr(), acc)(1234.5),
615            "1 234,50 €"
616        );
617        // Negative: sign before the whole amount, also before a prefix.
618        assert_eq!(
619            label_currency_locale(&Locale::de_de(), acc)(-1234.5),
620            "-1.234,50 €"
621        );
622        assert_eq!(
623            label_currency_locale(&Locale::en_us(), acc)(-1234.5),
624            "-$1,234.50"
625        );
626        // Rounds to zero → no sign.
627        assert_eq!(
628            label_currency_locale(&Locale::de_de(), acc)(-0.001),
629            "0,00 €"
630        );
631        // Rounding and large numbers.
632        let de0 = label_currency_locale(&Locale::de_de(), Some(1.0));
633        assert_eq!(de0(1_234_567.5), "1.234.568 €");
634        assert_eq!(de0(999.4), "999 €");
635        let de_trim = label_currency_locale(&Locale::de_de(), None);
636        assert_eq!(de_trim(1234.5), "1.234,5 €");
637        assert_eq!(de_trim(2_000_000.0), "2.000.000 €");
638        let chf = label_currency_locale(&Locale::de_de().with_currency("CHF"), acc);
639        assert_eq!(chf(1234.5), "1.234,50 CHF");
640    }
641
642    #[test]
643    fn numbers_per_locale() {
644        let cases: [(Locale, [&str; 5]); 4] = [
645            (
646                Locale::en_us(),
647                ["1,234.57", "-1,234.57", "0.10", "1,234,567,890.00", "0.00"],
648            ),
649            (
650                Locale::en_gb(),
651                ["1,234.57", "-1,234.57", "0.10", "1,234,567,890.00", "0.00"],
652            ),
653            (
654                Locale::de_de(),
655                ["1.234,57", "-1.234,57", "0,10", "1.234.567.890,00", "0,00"],
656            ),
657            (
658                Locale::fr_fr(),
659                ["1 234,57", "-1 234,57", "0,10", "1 234 567 890,00", "0,00"],
660            ),
661        ];
662        for (loc, want) in cases {
663            let f = label_number_locale(&loc, Some(0.01), "", "");
664            let got = [
665                f(1234.567),
666                f(-1234.567),
667                f(0.1),
668                f(1_234_567_890.0),
669                f(-0.004),
670            ];
671            assert_eq!(got.map(|s| s), want.map(String::from), "{}", loc.tag);
672        }
673        let de = Locale::de_de();
674        assert_eq!(label_comma_locale(&de)(-1_234_567.0), "-1.234.567");
675        assert_eq!(label_comma_locale(&de)(1234.567), "1.234,57");
676        assert_eq!(label_comma_locale(&Locale::fr_fr())(1234.5), "1 234,5");
677        let pre = label_number_locale(&de, None, "€", "");
678        assert_eq!(pre(-5.0), "-€5");
679    }
680
681    #[test]
682    fn percent_per_locale() {
683        let de = label_percent_locale(&Locale::de_de(), None);
684        assert_eq!(de(0.5), "50 %");
685        assert_eq!(de(0.123), "12,3 %");
686        assert_eq!(de(-0.25), "-25 %");
687        assert_eq!(de(12.345), "1.234,5 %");
688        let fr = label_percent_locale(&Locale::fr_fr(), Some(0.01));
689        assert_eq!(fr(0.12345), "12,35 %");
690        let us = label_percent_locale(&Locale::en_us(), None);
691        assert_eq!(us(0.123), "12.3%");
692        assert_eq!(
693            label_percent_locale(&Locale::en_gb(), Some(1.0))(0.506),
694            "51%"
695        );
696    }
697
698    #[test]
699    fn test_label_ordinal() {
700        let f = label_ordinal();
701        assert_eq!(f(1.0), "1st");
702        assert_eq!(f(2.0), "2nd");
703        assert_eq!(f(3.0), "3rd");
704        assert_eq!(f(4.0), "4th");
705        assert_eq!(f(11.0), "11th");
706        assert_eq!(f(22.0), "22nd");
707    }
708
709    #[test]
710    fn test_label_bytes() {
711        let f = label_bytes(false);
712        assert_eq!(f(500.0), "500 B");
713        assert_eq!(f(1500.0), "1.5 kB");
714        assert_eq!(f(2_000_000.0), "2 MB");
715        let b = label_bytes(true);
716        assert_eq!(b(1024.0), "1 KiB");
717    }
718}