Skip to main content

numeral_format/
lib.rs

1//! # numeral-format — format numbers with the numeral.js format-string DSL
2//!
3//! Format numbers using the familiar [numeral.js](http://numeraljs.com/) mini-language —
4//! thousands separators, fixed/optional decimals, abbreviations, currency, percentages,
5//! bytes, ordinals, forced signs, and parenthesized negatives:
6//!
7//! ```
8//! use numeral_format::format;
9//!
10//! assert_eq!(format(1000.0, "0,0"), "1,000");
11//! assert_eq!(format(1000.234, "0,0.00"), "1,000.23");
12//! assert_eq!(format(1_234_567.0, "0.0a"), "1.2m");
13//! assert_eq!(format(0.974_878, "0.000%"), "97.488%");
14//! assert_eq!(format(-1000.0, "($0,0)"), "($1,000)");
15//! assert_eq!(format(1000.0, "0b"), "1KB");
16//! assert_eq!(format(2.0, "0o"), "2nd");
17//! ```
18//!
19//! A faithful Rust port of the formatting half of the widely-used `numeral` npm package
20//! (the default `en` locale), which has no Rust equivalent. **Zero dependencies.**
21//!
22//! ## Scope
23//!
24//! This crate ports number → string *formatting* with the default `en` locale. It does not
25//! implement parsing formatted strings back to numbers (`unformat`), arithmetic helpers, or
26//! alternate locales. `NaN` is treated as numeral's `null` (formatted as zero).
27
28#![forbid(unsafe_code)]
29#![doc(html_root_url = "https://docs.rs/numeral-format/0.1.0")]
30// Index/length arithmetic here is over short format strings; conversions cannot overflow.
31#![allow(
32    clippy::cast_possible_truncation,
33    clippy::cast_possible_wrap,
34    clippy::cast_sign_loss,
35    // Exact float comparisons here intentionally mirror numeral.js's `===` checks.
36    clippy::float_cmp
37)]
38
39// Compile-test the README's examples as part of `cargo test`.
40#[cfg(doctest)]
41#[doc = include_str!("../README.md")]
42struct ReadmeDoctests;
43
44// `en` locale.
45const THOUSANDS: char = ',';
46const DECIMAL: char = '.';
47const ABBR_THOUSAND: &str = "k";
48const ABBR_MILLION: &str = "m";
49const ABBR_BILLION: &str = "b";
50const ABBR_TRILLION: &str = "t";
51const CURRENCY: &str = "$";
52
53/// Format `value` using a numeral.js format string (default `"0,0"` when empty).
54///
55/// `NaN` is treated as numeral's `null` and formats as zero.
56///
57/// ```
58/// # use numeral_format::format;
59/// assert_eq!(format(1234.5678, "0,0.00"), "1,234.57");
60/// assert_eq!(format(f64::NAN, "0,0"), "0");
61/// ```
62#[must_use]
63pub fn format(value: f64, format: &str) -> String {
64    let fmt = if format.is_empty() { "0,0" } else { format };
65    // numeral treats null (here: NaN) as a value; `value || 0` later coerces it to 0.
66    let value = if value.is_nan() { 0.0 } else { value };
67
68    // Dispatch in numeral's registration order: bps, bytes, currency, exponential,
69    // ordinal, percentage, time; default is the plain number format.
70    if contains(fmt, "BPS") {
71        format_bps(value, fmt)
72    } else if has_bytes_token(fmt) {
73        format_bytes(value, fmt)
74    } else if contains(fmt, "$") {
75        format_currency(value, fmt)
76    } else if contains(fmt, "e+") || contains(fmt, "e-") {
77        format_exponential(value, fmt)
78    } else if contains(fmt, "o") {
79        format_ordinal(value, fmt)
80    } else if contains(fmt, "%") {
81        format_percentage(value, fmt)
82    } else if contains(fmt, ":") {
83        format_time(value)
84    } else {
85        number_to_format(value, fmt)
86    }
87}
88
89fn contains(haystack: &str, needle: &str) -> bool {
90    haystack.contains(needle)
91}
92
93/// Matches numeral's bytes detector `/([0\s]i?b)/`.
94fn has_bytes_token(fmt: &str) -> bool {
95    let bytes = fmt.as_bytes();
96    let mut i = 0;
97    while i < bytes.len() {
98        if bytes[i] == b'0' || bytes[i].is_ascii_whitespace() {
99            let mut j = i + 1;
100            if j < bytes.len() && bytes[j] == b'i' {
101                j += 1;
102            }
103            if j < bytes.len() && bytes[j] == b'b' {
104                return true;
105            }
106        }
107        i += 1;
108    }
109    false
110}
111
112/// The core "separators, decimals, signs, abbreviations" formatter.
113#[allow(clippy::too_many_lines)]
114fn number_to_format(value: f64, format: &str) -> String {
115    let mut value = value;
116    let mut format = String::from(format);
117    let abs = value.abs();
118    let mut neg_paren = false;
119    // `signed`: Some(0) => prefix sign, Some(n>0) => suffix sign, None => no forced sign.
120    let mut signed: Option<usize> = None;
121
122    if format.contains('(') {
123        neg_paren = true;
124        format = format.replace(['(', ')'], "");
125    } else if format.contains('+') || format.contains('-') {
126        signed = if format.contains('+') {
127            format.find('+')
128        } else if value < 0.0 {
129            format.find('-')
130        } else {
131            None
132        };
133        format = format.replace(['+', '-'], "");
134    }
135
136    // Abbreviation.
137    let mut abbr = String::new();
138    let mut abbr_forced = false;
139    if format.contains('a') {
140        let abbr_force = match_abbr_force(&format);
141        abbr_forced = abbr_force.is_some();
142        if format.contains(" a") {
143            abbr.push(' ');
144        }
145        format = remove_abbr_token(&format, abbr.starts_with(' '));
146
147        let trillion = 1e12;
148        let billion = 1e9;
149        let million = 1e6;
150        let thousand = 1e3;
151        match abbr_force {
152            Some('t') => {
153                abbr.push_str(ABBR_TRILLION);
154                value /= trillion;
155            }
156            Some('b') => {
157                abbr.push_str(ABBR_BILLION);
158                value /= billion;
159            }
160            Some('m') => {
161                abbr.push_str(ABBR_MILLION);
162                value /= million;
163            }
164            Some('k') => {
165                abbr.push_str(ABBR_THOUSAND);
166                value /= thousand;
167            }
168            _ => {
169                if abs >= trillion {
170                    abbr.push_str(ABBR_TRILLION);
171                    value /= trillion;
172                } else if abs >= billion {
173                    abbr.push_str(ABBR_BILLION);
174                    value /= billion;
175                } else if abs >= million {
176                    abbr.push_str(ABBR_MILLION);
177                    value /= million;
178                } else if abs >= thousand {
179                    abbr.push_str(ABBR_THOUSAND);
180                    value /= thousand;
181                }
182            }
183        }
184    }
185
186    // Optional decimals `[.]`.
187    let mut opt_dec = false;
188    if format.contains("[.]") {
189        opt_dec = true;
190        format = format.replace("[.]", ".");
191    }
192
193    // `int_part` is assigned in both arms of the precision branch below (numeral computes an
194    // initial value here too, but always overwrites it).
195    let mut int_part: String;
196
197    let format_int = format.split('.').next().unwrap_or("");
198    let precision = format.split('.').nth(1);
199    let thousands_pos = format.find(',');
200    let leading_count = format_int
201        .split(',')
202        .next()
203        .unwrap_or("")
204        .bytes()
205        .filter(|&b| b == b'0')
206        .count();
207
208    let mut decimal = String::new();
209    if let Some(precision) = precision.filter(|p| !p.is_empty()) {
210        if precision.contains('[') {
211            let cleaned = precision.replace(']', "");
212            let mut split = cleaned.split('[');
213            let req = split.next().unwrap_or("");
214            let opt = split.next().unwrap_or("");
215            let fixed = to_fixed(value, req.len() + opt.len(), opt.len());
216            int_part = fixed.split('.').next().unwrap_or("").to_string();
217            decimal = fixed_decimal(&fixed);
218        } else {
219            let fixed = to_fixed(value, precision.len(), 0);
220            int_part = fixed.split('.').next().unwrap_or("").to_string();
221            decimal = fixed_decimal(&fixed);
222        }
223        if opt_dec && parse_num(decimal.get(1..).unwrap_or("")) == 0.0 {
224            decimal.clear();
225        }
226    } else {
227        int_part = to_fixed(value, 0, 0);
228    }
229
230    // Re-check abbreviation after rounding (e.g. 999_999 → "1,000k" becomes "1m").
231    if !abbr.is_empty()
232        && !abbr_forced
233        && parse_num(&int_part) >= 1000.0
234        && abbr.trim() != ABBR_TRILLION
235    {
236        int_part = js_num_to_string(parse_num(&int_part) / 1000.0);
237        let trimmed = abbr.trim().to_string();
238        let space = if abbr.starts_with(' ') { " " } else { "" };
239        abbr = match trimmed.as_str() {
240            ABBR_THOUSAND => format!("{space}{ABBR_MILLION}"),
241            ABBR_MILLION => format!("{space}{ABBR_BILLION}"),
242            ABBR_BILLION => format!("{space}{ABBR_TRILLION}"),
243            _ => abbr,
244        };
245    }
246
247    let mut neg = false;
248    if int_part.contains('-') {
249        int_part = int_part.trim_start_matches('-').to_string();
250        neg = true;
251    }
252
253    if int_part.len() < leading_count {
254        let pad = leading_count - int_part.len();
255        let mut padded = String::with_capacity(leading_count);
256        for _ in 0..pad {
257            padded.push('0');
258        }
259        padded.push_str(&int_part);
260        int_part = padded;
261    }
262
263    if thousands_pos.is_some() {
264        int_part = insert_thousands(&int_part);
265    }
266
267    if format.starts_with('.') {
268        int_part.clear();
269    }
270
271    let mut output = String::new();
272    output.push_str(&int_part);
273    output.push_str(&decimal);
274    output.push_str(&abbr);
275
276    if neg_paren {
277        if neg {
278            return format!("({output})");
279        }
280        output
281    } else {
282        match signed {
283            Some(0) => {
284                let sign = if neg { '-' } else { '+' };
285                format!("{sign}{output}")
286            }
287            Some(_) => {
288                let sign = if neg { '-' } else { '+' };
289                format!("{output}{sign}")
290            }
291            None => {
292                if neg {
293                    format!("-{output}")
294                } else {
295                    output
296                }
297            }
298        }
299    }
300}
301
302/// Find the abbreviation force letter from `/a(k|m|b|t)?/`.
303fn match_abbr_force(format: &str) -> Option<char> {
304    let bytes = format.as_bytes();
305    let pos = format.find('a')?;
306    bytes.get(pos + 1).and_then(|&b| match b {
307        b'k' | b'm' | b'b' | b't' => Some(b as char),
308        _ => None,
309    })
310}
311
312/// Remove the abbreviation token `(' ')?a[kmbt]?` (first occurrence).
313fn remove_abbr_token(format: &str, leading_space: bool) -> String {
314    let bytes = format.as_bytes();
315    let prefix = if leading_space { " a" } else { "a" };
316    if let Some(pos) = format.find(prefix) {
317        let mut end = pos + prefix.len();
318        if let Some(&b) = bytes.get(end) {
319            if matches!(b, b'k' | b'm' | b'b' | b't') {
320                end += 1;
321            }
322        }
323        let mut out = String::with_capacity(format.len());
324        out.push_str(&format[..pos]);
325        out.push_str(&format[end..]);
326        out
327    } else {
328        format.to_string()
329    }
330}
331
332/// numeral's `toFixed`: decimal-aware rounding that avoids binary float surprises.
333fn to_fixed(value: f64, max_decimals: usize, optionals: usize) -> String {
334    let s = js_num_to_string(value);
335    let min_decimals = max_decimals.saturating_sub(optionals);
336    let bounded = if let Some(frac) = s.split('.').nth(1) {
337        frac.len().max(min_decimals).min(max_decimals)
338    } else {
339        min_decimals
340    };
341
342    let power = 10f64.powi(bounded as i32);
343    let scaled: f64 = format!("{s}e{bounded}").parse().unwrap_or(f64::NAN);
344    let rounded = js_round(scaled);
345    let result = rounded / power;
346    let mut output = format_fixed(result, bounded);
347
348    if optionals > max_decimals - bounded {
349        let n = optionals - (max_decimals - bounded);
350        output = strip_trailing_optional_zeros(&output, n);
351    }
352    output
353}
354
355/// `Math.round`: round half toward +∞.
356fn js_round(x: f64) -> f64 {
357    (x + 0.5).floor()
358}
359
360/// `Number.prototype.toFixed(d)` for an already-rounded value.
361fn format_fixed(value: f64, decimals: usize) -> String {
362    // Guard against `-0.00`-style output: JS toFixed renders -0 as "0.00".
363    let value = if value == 0.0 { 0.0 } else { value };
364    format!("{value:.decimals$}")
365}
366
367/// Remove `/\.?0{1,n}$/` from the end.
368fn strip_trailing_optional_zeros(s: &str, n: usize) -> String {
369    let bytes = s.as_bytes();
370    let mut end = bytes.len();
371    let mut removed = 0;
372    while removed < n && end > 0 && bytes[end - 1] == b'0' {
373        end -= 1;
374        removed += 1;
375    }
376    if removed > 0 && end > 0 && bytes[end - 1] == b'.' {
377        end -= 1;
378    }
379    s[..end].to_string()
380}
381
382/// The decimal portion of a `to_fixed` result, prefixed with the locale decimal mark.
383fn fixed_decimal(fixed: &str) -> String {
384    match fixed.split_once('.') {
385        Some((_, frac)) => {
386            let mut d = String::with_capacity(frac.len() + 1);
387            d.push(DECIMAL);
388            d.push_str(frac);
389            d
390        }
391        None => String::new(),
392    }
393}
394
395/// Insert thousands separators every three digits from the right.
396fn insert_thousands(int_part: &str) -> String {
397    let digits: Vec<u8> = int_part.bytes().collect();
398    let len = digits.len();
399    let mut out = String::with_capacity(len + len / 3);
400    for (i, &d) in digits.iter().enumerate() {
401        if i > 0 && (len - i) % 3 == 0 {
402            out.push(THOUSANDS);
403        }
404        out.push(d as char);
405    }
406    out
407}
408
409/// `Number(str)` for already-numeric strings (returns 0.0 on failure, like our usage).
410fn parse_num(s: &str) -> f64 {
411    s.parse().unwrap_or(0.0)
412}
413
414/// `Number.prototype.toString()` for the magnitudes this crate targets.
415fn js_num_to_string(value: f64) -> String {
416    if value == 0.0 {
417        // Covers -0.0 too (JS `String(-0)` is "0").
418        return String::from("0");
419    }
420    value.to_string()
421}
422
423fn format_bytes(value: f64, format: &str) -> String {
424    let binary = contains(format, "ib");
425    let base: f64 = if binary { 1024.0 } else { 1000.0 };
426    let suffixes: &[&str] = if binary {
427        &["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
428    } else {
429        &["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
430    };
431    let mut suffix = if contains(format, " b") || contains(format, " ib") {
432        String::from(" ")
433    } else {
434        String::new()
435    };
436    let format = remove_first_match_bytes(format);
437
438    let mut value = value;
439    for power in 0..=suffixes.len() {
440        let min = base.powi(power as i32);
441        let max = base.powi(power as i32 + 1);
442        if value == 0.0 || (value >= min && value < max) {
443            if let Some(s) = suffixes.get(power) {
444                suffix.push_str(s);
445            }
446            if min > 0.0 {
447                value /= min;
448            }
449            break;
450        }
451    }
452
453    let mut out = number_to_format(value, &format);
454    out.push_str(&suffix);
455    out
456}
457
458/// Remove the first `\s?i?b` from a bytes format.
459fn remove_first_match_bytes(format: &str) -> String {
460    let bytes = format.as_bytes();
461    let mut i = 0;
462    while i < bytes.len() {
463        let start = i;
464        let mut j = i;
465        if bytes[j].is_ascii_whitespace() {
466            j += 1;
467        }
468        if j < bytes.len() && bytes[j] == b'i' {
469            j += 1;
470        }
471        if j < bytes.len() && bytes[j] == b'b' {
472            j += 1;
473            let mut out = String::with_capacity(format.len());
474            out.push_str(&format[..start]);
475            out.push_str(&format[j..]);
476            return out;
477        }
478        i += 1;
479    }
480    format.to_string()
481}
482
483fn format_currency(value: f64, format: &str) -> String {
484    let before = leading_currency_symbols(format);
485    let after = trailing_currency_symbols(format);
486    let format = remove_first_currency(format);
487    let mut output = number_to_format(value, &format);
488
489    let mut before: String = before;
490    let mut after: String = after;
491    if value >= 0.0 {
492        before = remove_first_of(&before, &['-', '(']);
493        after = remove_first_of(&after, &['-', ')']);
494    } else if !before.contains('-') && !before.contains('(') {
495        before.insert(0, '-');
496    }
497
498    let before_chars: Vec<char> = before.chars().collect();
499    for (i, &symbol) in before_chars.iter().enumerate() {
500        match symbol {
501            '$' => output = insert_at(&output, CURRENCY, i as isize),
502            ' ' => output = insert_at(&output, " ", (i + CURRENCY.len() - 1) as isize),
503            _ => {}
504        }
505    }
506
507    let after_chars: Vec<char> = after.chars().collect();
508    let alen = after_chars.len();
509    for i in (0..alen).rev() {
510        let symbol = after_chars[i];
511        match symbol {
512            '$' => {
513                output = if i == alen - 1 {
514                    let mut o = output;
515                    o.push_str(CURRENCY);
516                    o
517                } else {
518                    insert_at(&output, CURRENCY, -((alen - (1 + i)) as isize))
519                };
520            }
521            ' ' => {
522                output = if i == alen - 1 {
523                    let mut o = output;
524                    o.push(' ');
525                    o
526                } else {
527                    insert_at(
528                        &output,
529                        " ",
530                        -((alen - (1 + i) + CURRENCY.len() - 1) as isize),
531                    )
532                };
533            }
534            _ => {}
535        }
536    }
537    output
538}
539
540fn leading_currency_symbols(format: &str) -> String {
541    format
542        .chars()
543        .take_while(|c| matches!(c, '+' | '-' | '(' | ' ' | '$'))
544        .collect()
545}
546
547fn trailing_currency_symbols(format: &str) -> String {
548    let mut tail: Vec<char> = format
549        .chars()
550        .rev()
551        .take_while(|c| matches!(c, '+' | '-' | ')' | ' ' | '$'))
552        .collect();
553    tail.reverse();
554    tail.into_iter().collect()
555}
556
557fn remove_first_currency(format: &str) -> String {
558    // Strip `\s?\$\s?` (first occurrence).
559    if let Some(pos) = format.find('$') {
560        let bytes = format.as_bytes();
561        let mut start = pos;
562        if start > 0 && bytes[start - 1].is_ascii_whitespace() {
563            start -= 1;
564        }
565        let mut end = pos + 1;
566        if end < bytes.len() && bytes[end].is_ascii_whitespace() {
567            end += 1;
568        }
569        let mut out = String::with_capacity(format.len());
570        out.push_str(&format[..start]);
571        out.push_str(&format[end..]);
572        out
573    } else {
574        format.to_string()
575    }
576}
577
578fn remove_first_of(s: &str, chars: &[char]) -> String {
579    if let Some(pos) = s.find(|c| chars.contains(&c)) {
580        let mut out = String::with_capacity(s.len());
581        out.push_str(&s[..pos]);
582        out.push_str(&s[pos + 1..]);
583        out
584    } else {
585        s.to_string()
586    }
587}
588
589/// Insert `sub` into `s` at character index `at` (negative counts from the end), like
590/// numeral's `insert` with JS `slice` semantics.
591fn insert_at(s: &str, sub: &str, at: isize) -> String {
592    let chars: Vec<char> = s.chars().collect();
593    let len = chars.len() as isize;
594    let idx = if at < 0 {
595        (len + at).max(0)
596    } else {
597        at.min(len)
598    } as usize;
599    let mut out = String::with_capacity(s.len() + sub.len());
600    out.extend(chars[..idx].iter());
601    out.push_str(sub);
602    out.extend(chars[idx..].iter());
603    out
604}
605
606fn format_percentage(value: f64, format: &str) -> String {
607    let space = if contains(format, " %") { " " } else { "" };
608    let value = value * 100.0;
609    let format = strip_optional_space_token(format, "%");
610    let output = number_to_format(value, &format);
611    insert_suffix_before_paren(&output, space, "%")
612}
613
614fn format_ordinal(value: f64, format: &str) -> String {
615    let mut ordinal = if contains(format, " o") {
616        String::from(" ")
617    } else {
618        String::new()
619    };
620    let format = remove_first_ordinal(format);
621    ordinal.push_str(ordinal_suffix(value));
622    let mut out = number_to_format(value, &format);
623    out.push_str(&ordinal);
624    out
625}
626
627fn remove_first_ordinal(format: &str) -> String {
628    strip_optional_space_token(format, "o")
629}
630
631/// English ordinal suffix (the `en` locale `ordinal` function).
632///
633/// Mirrors numeral exactly: it operates on the raw (possibly fractional) value with strict
634/// equality, so e.g. `1.052` yields `"th"` (since `1.052 != 1`).
635fn ordinal_suffix(number: f64) -> &'static str {
636    let b = number % 10.0;
637    let tens = (number % 100.0 / 10.0).trunc() as i32;
638    if tens == 1 {
639        "th"
640    } else if b == 1.0 {
641        "st"
642    } else if b == 2.0 {
643        "nd"
644    } else if b == 3.0 {
645        "rd"
646    } else {
647        "th"
648    }
649}
650
651fn format_exponential(value: f64, format: &str) -> String {
652    let exponential = to_exponential(value);
653    let mut parts = exponential.split('e');
654    let mantissa: f64 = parts.next().unwrap_or("0").parse().unwrap_or(0.0);
655    let exp = parts.next().unwrap_or("+0");
656    let format = remove_first_exponential(format);
657    let mut out = number_to_format(mantissa, &format);
658    out.push('e');
659    out.push_str(exp);
660    out
661}
662
663/// `Number.prototype.toExponential()` (shortest), e.g. `12345` → `1.2345e+4`.
664fn to_exponential(value: f64) -> String {
665    if value == 0.0 {
666        return String::from("0e+0");
667    }
668    // Rust's `{:e}` gives e.g. "1.2345e4"; reformat the exponent with an explicit sign.
669    let s = format!("{value:e}");
670    let (mantissa, exp) = s.split_once('e').unwrap_or((s.as_str(), "0"));
671    let exp_num: i32 = exp.parse().unwrap_or(0);
672    let sign = if exp_num < 0 { '-' } else { '+' };
673    format!("{mantissa}e{sign}{}", exp_num.abs())
674}
675
676fn remove_first_exponential(format: &str) -> String {
677    // Strip `e[+|-]0` (first occurrence).
678    for marker in ["e+0", "e-0"] {
679        if let Some(pos) = format.find(marker) {
680            let mut out = String::with_capacity(format.len());
681            out.push_str(&format[..pos]);
682            out.push_str(&format[pos + marker.len()..]);
683            return out;
684        }
685    }
686    format.to_string()
687}
688
689fn format_time(value: f64) -> String {
690    let hours = (value / 3600.0).floor();
691    let minutes = ((value - hours * 3600.0) / 60.0).floor();
692    let seconds = js_round(value - hours * 3600.0 - minutes * 60.0);
693    let h = hours as i64;
694    let m = minutes as i64;
695    let s = seconds as i64;
696    format!("{h}:{m:02}:{s:02}")
697}
698
699fn format_bps(value: f64, format: &str) -> String {
700    let space = if contains(format, " BPS") { " " } else { "" };
701    let format = strip_optional_space_token(format, "BPS");
702    let output = number_to_format(value * 10000.0, &format);
703    insert_suffix_before_paren(&output, space, "BPS")
704}
705
706/// Strip `\s?<token>` (first occurrence) from a format string.
707fn strip_optional_space_token(format: &str, token: &str) -> String {
708    if let Some(pos) = format.find(token) {
709        let bytes = format.as_bytes();
710        let start = if pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
711            pos - 1
712        } else {
713            pos
714        };
715        let mut out = String::with_capacity(format.len());
716        out.push_str(&format[..start]);
717        out.push_str(&format[pos + token.len()..]);
718        out
719    } else {
720        format.to_string()
721    }
722}
723
724/// Append `space + suffix`, inserting it before a trailing `)` if the output is
725/// parenthesized (shared by the percentage and bps formats).
726fn insert_suffix_before_paren(output: &str, space: &str, suffix: &str) -> String {
727    if output.contains(')') {
728        let mut chars: Vec<char> = output.chars().collect();
729        let pos = chars.len().saturating_sub(1);
730        for (k, c) in format!("{space}{suffix}").chars().enumerate() {
731            chars.insert(pos + k, c);
732        }
733        chars.into_iter().collect()
734    } else {
735        format!("{output}{space}{suffix}")
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    #[test]
744    fn separators_and_decimals() {
745        assert_eq!(format(1000.0, "0,0"), "1,000");
746        assert_eq!(format(1000.234, "0,0.00"), "1,000.23");
747        assert_eq!(format(1234.5678, "0,0"), "1,235");
748        assert_eq!(format(0.0, "0.0000"), "0.0000");
749        assert_eq!(format(-0.5, "0,0.00"), "-0.50");
750    }
751
752    #[test]
753    fn optional_decimals() {
754        assert_eq!(format(3.5, "0[.]0"), "3.5");
755        assert_eq!(format(3.0, "0[.]0"), "3");
756    }
757
758    #[test]
759    fn abbreviations() {
760        assert_eq!(format(1_234_567.0, "0.0a"), "1.2m");
761        assert_eq!(format(1234.0, "0.00a"), "1.23k");
762        assert_eq!(format(1_000_000.0, "0.0a"), "1.0m");
763        assert_eq!(format(1_230_974.0, "0.000a"), "1.231m");
764    }
765
766    #[test]
767    fn signs_and_parens() {
768        assert_eq!(format(1.5, "+0,0"), "+2");
769        assert_eq!(format(-1000.0, "(0,0)"), "(1,000)");
770        assert_eq!(format(-1.5, "0.0"), "-1.5");
771    }
772
773    #[test]
774    fn currency() {
775        assert_eq!(format(1000.234, "$0,0.00"), "$1,000.23");
776        assert_eq!(format(-1000.0, "($0,0)"), "($1,000)");
777        assert_eq!(format(1000.0, "0,0 $"), "1,000 $");
778    }
779
780    #[test]
781    fn percentage() {
782        assert_eq!(format(0.974_878, "0.000%"), "97.488%");
783        assert_eq!(format(1.0, "0%"), "100%");
784    }
785
786    #[test]
787    fn bytes() {
788        assert_eq!(format(1000.0, "0b"), "1KB");
789        assert_eq!(format(1024.0, "0ib"), "1KiB");
790        assert_eq!(format(1052.0, "0.0b"), "1.1KB");
791    }
792
793    #[test]
794    fn ordinal() {
795        assert_eq!(format(1.0, "0o"), "1st");
796        assert_eq!(format(2.0, "0o"), "2nd");
797        assert_eq!(format(3.0, "0o"), "3rd");
798        assert_eq!(format(11.0, "0o"), "11th");
799        assert_eq!(format(21.0, "0o"), "21st");
800    }
801
802    #[test]
803    fn nan_is_zero() {
804        assert_eq!(format(f64::NAN, "0,0"), "0");
805    }
806}