Skip to main content

re_format/
lib.rs

1//! Miscellaneous tools to format and parse numbers, durations, etc.
2//!
3//! TODO(emilk): move some of this numeric formatting into `emath` so we can use it in `egui_plot`.
4
5mod duration;
6mod plural;
7pub mod time;
8
9use std::cmp::PartialOrd;
10use std::fmt::Display;
11
12pub use self::duration::DurationFormatOptions;
13pub use self::plural::{format_plural_s, format_plural_signed_s};
14
15// --- Numbers ---
16
17/// The minus character: <https://www.compart.com/en/unicode/U+2212>
18///
19/// Looks slightly different from the normal hyphen `-`.
20pub const MINUS: char = '−';
21
22/// A thin space, used for thousands separators, like `1 234`:
23///
24/// <https://en.wikipedia.org/wiki/Thin_space>
25pub const THIN_SPACE: char = '\u{2009}';
26
27/// Prepare a string containing a number for parsing
28pub fn strip_whitespace_and_normalize(text: &str) -> String {
29    text.chars()
30        // Ignore whitespace (trailing, leading, and thousands separators):
31        .filter(|c| !c.is_whitespace())
32        // Replace special minus character with normal minus (hyphen):
33        .map(|c| if c == MINUS { '-' } else { c })
34        .collect()
35}
36
37// TODO(rust-num/num-traits#315): waiting for https://github.com/rust-num/num-traits/issues/315 to land
38pub trait UnsignedAbs {
39    /// An unsigned type which is large enough to hold the absolute value of `Self`.
40    type Unsigned;
41
42    /// Computes the absolute value of `self` without any wrapping or panicking.
43    fn unsigned_abs(self) -> Self::Unsigned;
44}
45
46impl UnsignedAbs for i8 {
47    type Unsigned = u8;
48
49    #[inline]
50    fn unsigned_abs(self) -> Self::Unsigned {
51        self.unsigned_abs()
52    }
53}
54
55impl UnsignedAbs for i16 {
56    type Unsigned = u16;
57
58    #[inline]
59    fn unsigned_abs(self) -> Self::Unsigned {
60        self.unsigned_abs()
61    }
62}
63
64impl UnsignedAbs for i32 {
65    type Unsigned = u32;
66
67    #[inline]
68    fn unsigned_abs(self) -> Self::Unsigned {
69        self.unsigned_abs()
70    }
71}
72
73impl UnsignedAbs for i64 {
74    type Unsigned = u64;
75
76    #[inline]
77    fn unsigned_abs(self) -> Self::Unsigned {
78        self.unsigned_abs()
79    }
80}
81
82impl UnsignedAbs for i128 {
83    type Unsigned = u128;
84
85    #[inline]
86    fn unsigned_abs(self) -> Self::Unsigned {
87        self.unsigned_abs()
88    }
89}
90
91impl UnsignedAbs for isize {
92    type Unsigned = usize;
93
94    #[inline]
95    fn unsigned_abs(self) -> Self::Unsigned {
96        self.unsigned_abs()
97    }
98}
99
100/// Pretty format a signed number by using thousands separators for readability.
101///
102/// The returned value is for human eyes only, and can not be parsed
103/// by the normal `usize::from_str` function.
104pub fn format_int<Int>(number: Int) -> String
105where
106    Int: Display + PartialOrd + num_traits::Zero + UnsignedAbs,
107    Int::Unsigned: Display + num_traits::Unsigned,
108{
109    if number < Int::zero() {
110        format!("{MINUS}{}", format_uint(number.unsigned_abs()))
111    } else {
112        add_thousands_separators(&number.to_string())
113    }
114}
115
116/// Pretty format an unsigned integer by using thousands separators for readability.
117///
118/// The returned value is for human eyes only, and can not be parsed
119/// by the normal `usize::from_str` function.
120#[expect(clippy::needless_pass_by_value)]
121pub fn format_uint<Uint>(number: Uint) -> String
122where
123    Uint: Display + num_traits::Unsigned,
124{
125    add_thousands_separators(&number.to_string())
126}
127
128/// Add thousands separators to a number, every three steps,
129/// counting from the last character.
130fn add_thousands_separators(number: &str) -> String {
131    let mut chars = number.chars().rev().peekable();
132
133    let mut result = vec![];
134    while chars.peek().is_some() {
135        if !result.is_empty() {
136            // thousands-deliminator:
137            result.push(THIN_SPACE);
138        }
139        for _ in 0..3 {
140            if let Some(c) = chars.next() {
141                result.push(c);
142            }
143        }
144    }
145
146    result.reverse();
147    result.into_iter().collect()
148}
149
150#[test]
151fn test_format_uint() {
152    assert_eq!(format_uint(42_u32), "42");
153    assert_eq!(format_uint(999_u32), "999");
154    assert_eq!(format_uint(1_000_u32), "1 000");
155    assert_eq!(format_uint(123_456_u32), "123 456");
156    assert_eq!(format_uint(1_234_567_u32), "1 234 567");
157}
158
159/// Options for how to format a floating point number, e.g. an [`f64`].
160#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
161pub struct FloatFormatOptions {
162    /// Always show the sign, even if it is positive (`+`).
163    pub always_sign: bool,
164
165    /// Maximum digits of precision to use.
166    ///
167    /// This includes both the integer part and the fractional part.
168    pub precision: usize,
169
170    /// Max number of decimals to show after the decimal point.
171    ///
172    /// If not specified, [`Self::precision`] is used instead.
173    pub num_decimals: Option<usize>,
174
175    pub strip_trailing_zeros: bool,
176
177    /// Only add thousands separators to decimals if there are at least this many decimals.
178    pub min_decimals_for_thousands_separators: usize,
179}
180
181impl FloatFormatOptions {
182    /// Default options for formatting an [`half::f16`].
183    #[expect(non_upper_case_globals)]
184    pub const DEFAULT_f16: Self = Self {
185        always_sign: false,
186        precision: 5,
187        num_decimals: None,
188        strip_trailing_zeros: true,
189        min_decimals_for_thousands_separators: 6,
190    };
191
192    /// Default options for formatting an [`f32`].
193    #[expect(non_upper_case_globals)]
194    pub const DEFAULT_f32: Self = Self {
195        always_sign: false,
196        precision: 7,
197        num_decimals: None,
198        strip_trailing_zeros: true,
199        min_decimals_for_thousands_separators: 6,
200    };
201
202    /// Default options for formatting an [`f64`].
203    #[expect(non_upper_case_globals)]
204    pub const DEFAULT_f64: Self = Self {
205        always_sign: false,
206        precision: 15,
207        num_decimals: None,
208        strip_trailing_zeros: true,
209        min_decimals_for_thousands_separators: 6,
210    };
211
212    /// Always show the sign, even if it is positive (`+`).
213    #[inline]
214    pub fn with_always_sign(mut self, always_sign: bool) -> Self {
215        self.always_sign = always_sign;
216        self
217    }
218
219    /// Show at most this many digits of precision,
220    /// including both the integer part and the fractional part.
221    #[inline]
222    pub fn with_precision(mut self, precision: usize) -> Self {
223        self.precision = precision;
224        self
225    }
226
227    /// Max number of decimals to show after the decimal point.
228    ///
229    /// If not specified, [`Self::precision`] is used instead.
230    #[inline]
231    pub fn with_decimals(mut self, num_decimals: usize) -> Self {
232        self.num_decimals = Some(num_decimals);
233        self
234    }
235
236    /// Strip trailing zeros from decimal expansion?
237    #[inline]
238    pub fn with_strip_trailing_zeros(mut self, strip_trailing_zeros: bool) -> Self {
239        self.strip_trailing_zeros = strip_trailing_zeros;
240        self
241    }
242
243    /// The returned value is for human eyes only, and can not be parsed
244    /// by the normal `f64::from_str` function.
245    pub fn format(&self, value: impl Into<f64>) -> String {
246        self.format_f64(value.into())
247    }
248
249    fn format_f64(&self, mut value: f64) -> String {
250        fn reverse(s: &str) -> String {
251            s.chars().rev().collect()
252        }
253
254        let Self {
255            always_sign,
256            precision,
257            num_decimals,
258            strip_trailing_zeros,
259            min_decimals_for_thousands_separators,
260        } = *self;
261
262        if value.is_nan() {
263            return "NaN".to_owned();
264        }
265
266        let sign = if value < 0.0 {
267            value = -value;
268            "−" // NOTE: the minus character: <https://www.compart.com/en/unicode/U+2212>
269        } else if always_sign {
270            "+"
271        } else {
272            ""
273        };
274
275        let abs_string = if value == f64::INFINITY {
276            "∞".to_owned()
277        } else {
278            let magnitude = value.log10();
279            let max_decimals = precision as f64 - magnitude.max(0.0);
280
281            if max_decimals < 0.0 {
282                // A very large number (more digits than we have precision),
283                // so use scientific notation.
284                // TODO(emilk): nice formatting of scientific notation with thousands separators
285                format!("{:.*e}", precision.saturating_sub(1), value)
286            } else {
287                let max_decimals = max_decimals as usize;
288
289                let num_decimals = if let Some(num_decimals) = num_decimals {
290                    num_decimals.min(max_decimals)
291                } else {
292                    max_decimals
293                };
294
295                let mut formatted = format!("{value:.num_decimals$}");
296
297                if strip_trailing_zeros && formatted.contains('.') {
298                    while formatted.ends_with('0') {
299                        formatted.pop();
300                    }
301                    if formatted.ends_with('.') {
302                        formatted.pop();
303                    }
304                }
305
306                if let Some(dot) = formatted.find('.') {
307                    let integer_part = &formatted[..dot];
308                    let fractional_part = &formatted[dot + 1..];
309                    // let fractional_part = &fractional_part[..num_decimals.min(fractional_part.len())];
310
311                    let integer_part = add_thousands_separators(integer_part);
312
313                    if fractional_part.len() < min_decimals_for_thousands_separators {
314                        format!("{integer_part}.{fractional_part}")
315                    } else {
316                        // For the fractional part we should start counting thousand separators from the _front_, so we reverse:
317                        let fractional_part =
318                            reverse(&add_thousands_separators(&reverse(fractional_part)));
319                        format!("{integer_part}.{fractional_part}")
320                    }
321                } else {
322                    add_thousands_separators(&formatted) // it's an integer
323                }
324            }
325        };
326
327        format!("{sign}{abs_string}")
328    }
329}
330
331/// Format a number with about 15 decimals of precision.
332///
333/// The returned value is for human eyes only, and can not be parsed
334/// by the normal `f64::from_str` function.
335pub fn format_f64(value: f64) -> String {
336    FloatFormatOptions::DEFAULT_f64.format(value)
337}
338
339/// Format a number with about 7 decimals of precision.
340///
341/// The returned value is for human eyes only, and can not be parsed
342/// by the normal `f64::from_str` function.
343pub fn format_f32(value: f32) -> String {
344    FloatFormatOptions::DEFAULT_f32.format(value)
345}
346
347/// Format a number with about 5 decimals of precision.
348///
349/// The returned value is for human eyes only, and can not be parsed
350/// by the normal `f64::from_str` function.
351pub fn format_f16(value: half::f16) -> String {
352    FloatFormatOptions::DEFAULT_f16.format(value)
353}
354
355/// Format a latitude or longitude value.
356///
357/// For human eyes only.
358pub fn format_lat_lon(value: f64) -> String {
359    format!(
360        "{}°",
361        FloatFormatOptions {
362            always_sign: true,
363            precision: 10,
364            num_decimals: Some(6),
365            strip_trailing_zeros: false,
366            min_decimals_for_thousands_separators: 10,
367        }
368        .format_f64(value)
369    )
370}
371
372#[test]
373fn test_format_f32() {
374    let cases = [
375        (f32::NAN, "NaN"),
376        (f32::INFINITY, "∞"),
377        (f32::NEG_INFINITY, "−∞"),
378        (0.0, "0"),
379        (42.0, "42"),
380        (10_000.0, "10 000"),
381        (1_000_000.0, "1 000 000"),
382        (10_000_000.0, "10 000 000"),
383        (11_000_000.0, "1.100000e7"),
384        (-42.0, "−42"),
385        (-4.20, "−4.2"),
386        (123_456.78, "123 456.8"),
387        (78.4321, "78.4321"), // min_decimals_for_thousands_separators
388        (-std::f32::consts::PI, "−3.141 593"),
389        (-std::f32::consts::PI * 1e6, "−3 141 593"),
390        (-std::f32::consts::PI * 1e20, "−3.141593e20"), // We switch to scientific notation to not show false precision
391    ];
392    for (value, expected) in cases {
393        let got = format_f32(value);
394        assert!(
395            got == expected,
396            "Expected to format {value} as '{expected}', but got '{got}'"
397        );
398    }
399}
400
401#[test]
402fn test_format_f64() {
403    let cases = [
404        (f64::NAN, "NaN"),
405        (f64::INFINITY, "∞"),
406        (f64::NEG_INFINITY, "−∞"),
407        (0.0, "0"),
408        (42.0, "42"),
409        (-42.0, "−42"),
410        (-4.20, "−4.2"),
411        (123_456_789.0, "123 456 789"),
412        (123_456_789.123_45, "123 456 789.12345"), // min_decimals_for_thousands_separators
413        (0.0000123456789, "0.000 012 345 678 9"),
414        (0.123456789, "0.123 456 789"),
415        (1.23456789, "1.234 567 89"),
416        (12.3456789, "12.345 678 9"),
417        (123.456789, "123.456 789"),
418        (1234.56789, "1 234.56789"), // min_decimals_for_thousands_separators
419        (12345.6789, "12 345.6789"), // min_decimals_for_thousands_separators
420        (78.4321, "78.4321"),        // min_decimals_for_thousands_separators
421        (-std::f64::consts::PI, "−3.141 592 653 589 79"),
422        (-std::f64::consts::PI * 1e6, "−3 141 592.653 589 79"),
423        (-std::f64::consts::PI * 1e20, "−3.14159265358979e20"), // We switch to scientific notation to not show false precision
424    ];
425    for (value, expected) in cases {
426        let got = format_f64(value);
427        assert!(
428            got == expected,
429            "Expected to format {value} as '{expected}', but got '{got}'"
430        );
431    }
432}
433
434#[test]
435fn test_format_f16() {
436    use half::f16;
437
438    let cases = [
439        (f16::from_f32(f32::NAN), "NaN"),
440        (f16::INFINITY, "∞"),
441        (f16::NEG_INFINITY, "−∞"),
442        (f16::ZERO, "0"),
443        (f16::from_f32(42.0), "42"),
444        (f16::from_f32(-42.0), "−42"),
445        (f16::from_f32(-4.20), "−4.1992"), // f16 precision limitation
446        (f16::from_f32(12_345.0), "12 344"), // f16 precision limitation
447        (f16::PI, "3.1406"),               // f16 precision limitation
448    ];
449    for (value, expected) in cases {
450        let got = format_f16(value);
451        assert_eq!(
452            got, expected,
453            "Expected to format {value} as '{expected}', but got '{got}'"
454        );
455    }
456}
457
458#[test]
459fn test_format_f64_custom() {
460    let cases = [(
461        FloatFormatOptions::DEFAULT_f64.with_decimals(2),
462        123.456789,
463        "123.46",
464    )];
465    for (options, value, expected) in cases {
466        let got = options.format(value);
467        assert!(
468            got == expected,
469            "Expected to format {value} as '{expected}', but got '{got}'. Options: {options:#?}"
470        );
471    }
472}
473
474/// Parses a number, ignoring whitespace (e.g. thousand separators),
475/// and treating the special minus character `MINUS` (−) as a minus sign.
476pub fn parse_f64(text: &str) -> Option<f64> {
477    let text = strip_whitespace_and_normalize(text);
478    text.parse().ok()
479}
480
481/// Parses a number, ignoring whitespace (e.g. thousand separators),
482/// and treating the special minus character `MINUS` (−) as a minus sign.
483pub fn parse_i64(text: &str) -> Option<i64> {
484    let text = strip_whitespace_and_normalize(text);
485    text.parse().ok()
486}
487
488/// Pretty format a large number by using SI notation (base 10), e.g.
489///
490/// ```
491/// # use re_format::approximate_large_number;
492/// assert_eq!(approximate_large_number(123 as _), "123");
493/// assert_eq!(approximate_large_number(12_345 as _), "12k");
494/// assert_eq!(approximate_large_number(1_234_567 as _), "1.2M");
495/// assert_eq!(approximate_large_number(123_456_789 as _), "123M");
496/// ```
497///
498/// Prefer to use [`format_uint`], which outputs an exact string,
499/// while still being readable thanks to half-width spaces used as thousands-separators.
500pub fn approximate_large_number(number: f64) -> String {
501    if number < 0.0 {
502        format!("{MINUS}{}", approximate_large_number(-number))
503    } else if number < 1000.0 {
504        format!("{number:.0}")
505    } else if number < 1_000_000.0 {
506        let decimals = (number < 10_000.0) as usize;
507        format!("{:.*}k", decimals, number / 1_000.0)
508    } else if number < 1_000_000_000.0 {
509        let decimals = (number < 10_000_000.0) as usize;
510        format!("{:.*}M", decimals, number / 1_000_000.0)
511    } else {
512        let decimals = (number < 10_000_000_000.0) as usize;
513        format!("{:.*}G", decimals, number / 1_000_000_000.0)
514    }
515}
516
517#[test]
518fn test_format_large_number() {
519    let test_cases = [
520        (999.0, "999"),
521        (1000.0, "1.0k"),
522        (1001.0, "1.0k"),
523        (999_999.0, "1000k"),
524        (1_000_000.0, "1.0M"),
525        (999_999_999.0, "1000M"),
526        (1_000_000_000.0, "1.0G"),
527        (999_999_999_999.0, "1000G"),
528        (1_000_000_000_000.0, "1000G"),
529        (123.0, "123"),
530        (12_345.0, "12k"),
531        (1_234_567.0, "1.2M"),
532        (123_456_789.0, "123M"),
533    ];
534
535    for (value, expected) in test_cases {
536        assert_eq!(expected, approximate_large_number(value));
537    }
538}
539
540// --- Bytes ---
541
542/// Pretty format a number of bytes by using SI notation (base2), e.g.
543///
544/// ```
545/// # use re_format::format_bytes;
546/// assert_eq!(format_bytes(123.0), "123 B");
547/// assert_eq!(format_bytes(12_345.0), "12.1 KiB");
548/// assert_eq!(format_bytes(1_234_567.0), "1.2 MiB");
549/// assert_eq!(format_bytes(123_456_789.0), "118 MiB");
550/// ```
551pub fn format_bytes(number_of_bytes: f64) -> String {
552    if number_of_bytes < 0.0 {
553        format!("{MINUS}{}", format_bytes(-number_of_bytes))
554    } else if number_of_bytes == 0.0 {
555        "0 B".to_owned()
556    } else if number_of_bytes < 1.0 {
557        format!("{number_of_bytes} B")
558    } else if number_of_bytes < 20.0 {
559        let is_integer = number_of_bytes.round() == number_of_bytes;
560        if is_integer {
561            format!("{number_of_bytes:.0} B")
562        } else {
563            format!("{number_of_bytes:.1} B")
564        }
565    } else if number_of_bytes < 10.0_f64.exp2() {
566        format!("{number_of_bytes:.0} B")
567    } else if number_of_bytes < 20.0_f64.exp2() {
568        let decimals = (10.0 * number_of_bytes < 20.0_f64.exp2()) as usize;
569        format!("{:.*} KiB", decimals, number_of_bytes / 10.0_f64.exp2())
570    } else if number_of_bytes < 30.0_f64.exp2() {
571        let decimals = (10.0 * number_of_bytes < 30.0_f64.exp2()) as usize;
572        format!("{:.*} MiB", decimals, number_of_bytes / 20.0_f64.exp2())
573    } else {
574        let decimals = (10.0 * number_of_bytes < 40.0_f64.exp2()) as usize;
575        format!("{:.*} GiB", decimals, number_of_bytes / 30.0_f64.exp2())
576    }
577}
578
579#[test]
580fn test_format_bytes() {
581    let test_cases = [
582        (0.0, "0 B"),
583        (0.25, "0.25 B"),
584        (1.51, "1.5 B"),
585        (11.0, "11 B"),
586        (12.5, "12.5 B"),
587        (999.0, "999 B"),
588        (1000.0, "1000 B"),
589        (1001.0, "1001 B"),
590        (1023.0, "1023 B"),
591        (1024.0, "1.0 KiB"),
592        (1025.0, "1.0 KiB"),
593        (1024.0 * 1.2345, "1.2 KiB"),
594        (1024.0 * 12.345, "12.3 KiB"),
595        (1024.0 * 123.45, "123 KiB"),
596        (1024f64.powi(2) - 1.0, "1024 KiB"),
597        (1024f64.powi(2) + 0.0, "1.0 MiB"),
598        (1024f64.powi(2) + 1.0, "1.0 MiB"),
599        (1024f64.powi(3) - 1.0, "1024 MiB"),
600        (1024f64.powi(3) + 0.0, "1.0 GiB"),
601        (1024f64.powi(3) + 1.0, "1.0 GiB"),
602        (1.2345 * 30.0_f64.exp2(), "1.2 GiB"),
603        (12.345 * 30.0_f64.exp2(), "12.3 GiB"),
604        (123.45 * 30.0_f64.exp2(), "123 GiB"),
605        (1024f64.powi(4) - 1.0, "1024 GiB"),
606        (1024f64.powi(4) + 0.0, "1024 GiB"),
607        (1024f64.powi(4) + 1.0, "1024 GiB"),
608        (123.0, "123 B"),
609        (12_345.0, "12.1 KiB"),
610        (1_234_567.0, "1.2 MiB"),
611        (123_456_789.0, "118 MiB"),
612    ];
613
614    for (value, expected) in test_cases {
615        assert_eq!(format_bytes(value), expected);
616    }
617}
618
619pub fn parse_bytes_base10(bytes: &str) -> Option<i64> {
620    let bytes = strip_whitespace_and_normalize(bytes);
621
622    if bytes == "0" {
623        return Some(0);
624    }
625
626    // Note: intentionally case sensitive so that we don't parse `Mb` (Megabit) as `MB` (Megabyte).
627    if let Some(rest) = bytes.strip_prefix(MINUS) {
628        Some(-parse_bytes_base10(rest)?)
629    } else if let Some(kb) = bytes.strip_suffix("kB") {
630        Some((kb.parse::<f64>().ok()? * 1e3) as _)
631    } else if let Some(mb) = bytes.strip_suffix("MB") {
632        Some((mb.parse::<f64>().ok()? * 1e6) as _)
633    } else if let Some(gb) = bytes.strip_suffix("GB") {
634        Some((gb.parse::<f64>().ok()? * 1e9) as _)
635    } else if let Some(tb) = bytes.strip_suffix("TB") {
636        Some((tb.parse::<f64>().ok()? * 1e12) as _)
637    } else if let Some(b) = bytes.strip_suffix('B') {
638        Some(b.parse::<i64>().ok()?)
639    } else {
640        None
641    }
642}
643
644#[test]
645fn test_parse_bytes_base10() {
646    let test_cases = [
647        ("0", 0), // Zero requires no unit
648        ("-1B", -1),
649        ("999B", 999),
650        ("1000B", 1_000),
651        ("1kB", 1_000),
652        ("1000kB", 1_000_000),
653        ("1MB", 1_000_000),
654        ("1000MB", 1_000_000_000),
655        ("1GB", 1_000_000_000),
656        ("1000GB", 1_000_000_000_000),
657        ("1TB", 1_000_000_000_000),
658        ("1000TB", 1_000_000_000_000_000),
659        ("123B", 123),
660        ("12kB", 12_000),
661        ("123MB", 123_000_000),
662        ("-10B", -10), // hyphen-minus
663        ("−10B", -10), // proper minus
664    ];
665    for (value, expected) in test_cases {
666        assert_eq!(Some(expected), parse_bytes_base10(value));
667    }
668}
669
670pub fn parse_bytes_base2(bytes: &str) -> Option<i64> {
671    let bytes = strip_whitespace_and_normalize(bytes);
672
673    if bytes == "0" {
674        return Some(0);
675    }
676
677    // Note: intentionally case sensitive so that we don't parse `Mib` (Mebibit) as `MiB` (Mebibyte).
678    if let Some(rest) = bytes.strip_prefix(MINUS) {
679        Some(-parse_bytes_base2(rest)?)
680    } else if let Some(kb) = bytes.strip_suffix("KiB") {
681        Some((kb.parse::<f64>().ok()? * 1024.0) as _)
682    } else if let Some(mb) = bytes.strip_suffix("MiB") {
683        Some((mb.parse::<f64>().ok()? * 1024.0 * 1024.0) as _)
684    } else if let Some(gb) = bytes.strip_suffix("GiB") {
685        Some((gb.parse::<f64>().ok()? * 1024.0 * 1024.0 * 1024.0) as _)
686    } else if let Some(tb) = bytes.strip_suffix("TiB") {
687        Some((tb.parse::<f64>().ok()? * 1024.0 * 1024.0 * 1024.0 * 1024.0) as _)
688    } else if let Some(b) = bytes.strip_suffix('B') {
689        Some(b.parse::<i64>().ok()?)
690    } else {
691        None
692    }
693}
694
695#[test]
696fn test_parse_bytes_base2() {
697    let test_cases = [
698        ("0", 0), // Zero requires no unit
699        ("-1B", -1),
700        ("999B", 999),
701        ("1023B", 1_023),
702        ("1024B", 1_024),
703        ("1KiB", 1_024),
704        ("1000KiB", 1_000 * 1024),
705        ("1MiB", 1024 * 1024),
706        ("1000MiB", 1_000 * 1024 * 1024),
707        ("1GiB", 1024 * 1024 * 1024),
708        ("1000GiB", 1_000 * 1024 * 1024 * 1024),
709        ("1TiB", 1024 * 1024 * 1024 * 1024),
710        ("1000TiB", 1_000 * 1024 * 1024 * 1024 * 1024),
711        ("123B", 123),
712        ("12KiB", 12 * 1024),
713        ("123MiB", 123 * 1024 * 1024),
714        ("-10B", -10), // hyphen-minus
715        ("−10B", -10), // proper minus
716    ];
717    for (value, expected) in test_cases {
718        assert_eq!(Some(expected), parse_bytes_base2(value));
719    }
720}
721
722pub fn parse_bytes(bytes: &str) -> Option<i64> {
723    parse_bytes_base10(bytes).or_else(|| parse_bytes_base2(bytes))
724}
725
726#[test]
727fn test_parse_bytes() {
728    let test_cases = [
729        // base10
730        ("0", 0), // Zero requires no unit
731        ("-1B", -1),
732        ("999B", 999),
733        ("1000B", 1_000),
734        ("1kB", 1_000),
735        ("1000kB", 1_000_000),
736        ("1MB", 1_000_000),
737        ("1000MB", 1_000_000_000),
738        ("1GB", 1_000_000_000),
739        ("1000GB", 1_000_000_000_000),
740        ("1TB", 1_000_000_000_000),
741        ("1000TB", 1_000_000_000_000_000),
742        ("123B", 123),
743        ("12kB", 12_000),
744        ("123MB", 123_000_000),
745        // base2
746        ("999B", 999),
747        ("1023B", 1_023),
748        ("1024B", 1_024),
749        ("1KiB", 1_024),
750        ("1000KiB", 1_000 * 1024),
751        ("1MiB", 1024 * 1024),
752        ("1000MiB", 1_000 * 1024 * 1024),
753        ("1GiB", 1024 * 1024 * 1024),
754        ("1000GiB", 1_000 * 1024 * 1024 * 1024),
755        ("1TiB", 1024 * 1024 * 1024 * 1024),
756        ("1000TiB", 1_000 * 1024 * 1024 * 1024 * 1024),
757        ("123B", 123),
758        ("12KiB", 12 * 1024),
759        ("123MiB", 123 * 1024 * 1024),
760    ];
761    for (value, expected) in test_cases {
762        assert_eq!(Some(expected), parse_bytes(value));
763    }
764}
765
766// --- Durations ---
767
768pub fn parse_duration(duration: &str) -> Result<f32, String> {
769    fn parse_num(s: &str) -> Result<f32, String> {
770        s.parse()
771            .map_err(|_ignored| format!("Expected a number, got {s:?}"))
772    }
773
774    if let Some(ms) = duration.strip_suffix("ms") {
775        Ok(parse_num(ms)? * 1e-3)
776    } else if let Some(s) = duration.strip_suffix('s') {
777        Ok(parse_num(s)?)
778    } else if let Some(s) = duration.strip_suffix('m') {
779        Ok(parse_num(s)? * 60.0)
780    } else if let Some(s) = duration.strip_suffix('h') {
781        Ok(parse_num(s)? * 60.0 * 60.0)
782    } else {
783        Err(format!(
784            "Expected a suffix of 'ms', 's', 'm' or 'h' in string {duration:?}"
785        ))
786    }
787}
788
789#[test]
790fn test_parse_duration() {
791    assert_eq!(parse_duration("3.2s"), Ok(3.2));
792    assert_eq!(parse_duration("250ms"), Ok(0.250));
793    assert_eq!(parse_duration("3m"), Ok(3.0 * 60.0));
794}
795
796/// Remove the custom formatting
797///
798/// Removes the thin spaces and the special minus character. Useful when copying text.
799pub fn remove_number_formatting(s: &str) -> String {
800    s.chars()
801        .filter_map(|c| {
802            if c == MINUS {
803                Some('-')
804            } else if c == THIN_SPACE {
805                None
806            } else {
807                Some(c)
808            }
809        })
810        .collect()
811}
812
813#[test]
814fn test_remove_number_formatting() {
815    assert_eq!(
816        remove_number_formatting(&format_f32(-123_456.78)),
817        "-123456.8"
818    );
819    assert_eq!(
820        remove_number_formatting(&format_f64(-123_456.78)),
821        "-123456.78"
822    );
823    assert_eq!(
824        remove_number_formatting(&format_int(-123_456_789_i32)),
825        "-123456789"
826    );
827    assert_eq!(
828        remove_number_formatting(&format_uint(123_456_789_u32)),
829        "123456789"
830    );
831}