Skip to main content

rusty_pv/
units.rs

1//! IEC binary + SI decimal unit-suffix math (FR-026/FR-027, AD-015).
2
3/// Selector for unit-suffix system.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5pub enum UnitSystem {
6    /// IEC binary prefixes (KiB = 1024, MiB = 1024², ...). Default.
7    #[default]
8    Iec,
9    /// SI decimal prefixes (kB = 1000, MB = 10⁶, ...).
10    Si,
11}
12
13const IEC_SUFFIXES: &[(u64, &str)] = &[
14    (1, "B"),
15    (1 << 10, "KiB"),
16    (1 << 20, "MiB"),
17    (1 << 30, "GiB"),
18    (1u64 << 40, "TiB"),
19];
20
21const SI_SUFFIXES: &[(u64, &str)] = &[
22    (1, "B"),
23    (1_000, "kB"),
24    (1_000_000, "MB"),
25    (1_000_000_000, "GB"),
26    (1_000_000_000_000, "TB"),
27];
28
29impl UnitSystem {
30    fn suffixes(self) -> &'static [(u64, &'static str)] {
31        match self {
32            UnitSystem::Iec => IEC_SUFFIXES,
33            UnitSystem::Si => SI_SUFFIXES,
34        }
35    }
36
37    /// Format a byte count with the largest suffix `<=` value. Returns a
38    /// string like `"45.2MiB"` or `"500B"`. 3 significant digits when the
39    /// value is `<1000` of the chosen unit; integer when `<10`.
40    #[must_use]
41    pub fn format_bytes(self, value: u64) -> String {
42        let table = self.suffixes();
43        let mut best = &table[0];
44        for entry in table {
45            if value >= entry.0 {
46                best = entry;
47            }
48        }
49        let scaled = value as f64 / best.0 as f64;
50        if best.0 == 1 {
51            format!("{}{}", value, best.1)
52        } else if scaled >= 100.0 {
53            format!("{scaled:.0}{}", best.1)
54        } else if scaled >= 10.0 {
55            format!("{scaled:.1}{}", best.1)
56        } else {
57            format!("{scaled:.2}{}", best.1)
58        }
59    }
60
61    /// Format a rate value (bytes per second) by appending `/s` to the byte
62    /// format.
63    #[must_use]
64    pub fn format_rate(self, bytes_per_sec: f64) -> String {
65        let v = bytes_per_sec.max(0.0) as u64;
66        format!("{}/s", self.format_bytes(v))
67    }
68}
69
70/// Parse a size string with optional `K`/`M`/`G`/`T` suffix (IEC by default;
71/// SI when `unit_system == Si`). Accepts both integer and fractional values.
72/// Returns the byte count.
73///
74/// # Errors
75///
76/// Returns `None` for malformed input or out-of-range values.
77pub fn parse_size(s: &str, unit_system: UnitSystem) -> Option<u64> {
78    let s = s.trim();
79    if s.is_empty() {
80        return None;
81    }
82    let table = unit_system.suffixes();
83    // Find the suffix (case-insensitive single-letter K/M/G/T or full IEC/SI suffix).
84    let (num_part, mult): (&str, u64) = if let Some(pos) = s.find(|c: char| c.is_ascii_alphabetic())
85    {
86        let (num, suffix) = s.split_at(pos);
87        let m = match suffix.chars().next()?.to_ascii_uppercase() {
88            'B' => 1,
89            'K' => table[1].0,
90            'M' => table[2].0,
91            'G' => table[3].0,
92            'T' => table[4].0,
93            _ => return None,
94        };
95        (num, m)
96    } else {
97        (s, 1)
98    };
99    let n: f64 = num_part.trim().parse().ok()?;
100    if n < 0.0 || !n.is_finite() {
101        return None;
102    }
103    Some((n * mult as f64) as u64)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn format_iec_bytes() {
112        assert_eq!(UnitSystem::Iec.format_bytes(0), "0B");
113        assert_eq!(UnitSystem::Iec.format_bytes(512), "512B");
114        assert_eq!(UnitSystem::Iec.format_bytes(1024), "1.00KiB");
115        assert_eq!(UnitSystem::Iec.format_bytes(1_048_576), "1.00MiB");
116        assert_eq!(UnitSystem::Iec.format_bytes(47_185_920), "45.0MiB");
117    }
118
119    #[test]
120    fn format_si_bytes() {
121        assert_eq!(UnitSystem::Si.format_bytes(1000), "1.00kB");
122        assert_eq!(UnitSystem::Si.format_bytes(1_000_000), "1.00MB");
123    }
124
125    #[test]
126    fn parse_iec_default() {
127        assert_eq!(parse_size("1024", UnitSystem::Iec), Some(1024));
128        assert_eq!(parse_size("1K", UnitSystem::Iec), Some(1024));
129        assert_eq!(parse_size("1M", UnitSystem::Iec), Some(1_048_576));
130        assert_eq!(parse_size("1.5M", UnitSystem::Iec), Some(1_572_864));
131    }
132
133    #[test]
134    fn parse_si() {
135        assert_eq!(parse_size("1k", UnitSystem::Si), Some(1000));
136        assert_eq!(parse_size("1M", UnitSystem::Si), Some(1_000_000));
137    }
138
139    #[test]
140    fn parse_rejects_negative() {
141        assert_eq!(parse_size("-5M", UnitSystem::Iec), None);
142    }
143
144    #[test]
145    fn parse_rejects_garbage() {
146        assert_eq!(parse_size("abc", UnitSystem::Iec), None);
147        assert_eq!(parse_size("", UnitSystem::Iec), None);
148    }
149
150    #[test]
151    fn format_rate_appends_per_second() {
152        assert_eq!(UnitSystem::Iec.format_rate(1024.0), "1.00KiB/s");
153    }
154}