Skip to main content

gravityfile_core/
units.rs

1//! Human-readable sizes and ages, and parsers for their command-line forms.
2
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6
7/// How sizes are displayed.
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum Units {
11    /// Powers of 1000 (`kB`, `MB`, `GB`), matching Finder and most file managers.
12    #[default]
13    Si,
14    /// Powers of 1024 (`KiB`, `MiB`, `GiB`), matching `du -h`.
15    Binary,
16}
17
18impl Units {
19    fn table(self) -> (f64, [&'static str; 7]) {
20        match self {
21            Units::Si => (1000.0, ["B", "kB", "MB", "GB", "TB", "PB", "EB"]),
22            Units::Binary => (1024.0, ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]),
23        }
24    }
25}
26
27/// Format a byte count with three significant digits: `999 B`, `1.50 kB`,
28/// `52.3 GB`, `140 GB`.
29pub fn format_size(bytes: u64, units: Units) -> String {
30    let (base, names) = units.table();
31    if (bytes as f64) < base {
32        return format!("{bytes} B");
33    }
34    let mut value = bytes as f64;
35    let mut unit = 0;
36    while value >= base && unit < names.len() - 1 {
37        value /= base;
38        unit += 1;
39    }
40    // Rounding can carry into the next unit (999.96 kB -> "1000 kB").
41    if value >= 999.5 && unit < names.len() - 1 {
42        value /= base;
43        unit += 1;
44    }
45    let name = names[unit];
46    if value >= 99.95 {
47        format!("{value:.0} {name}")
48    } else if value >= 9.995 {
49        format!("{value:.1} {name}")
50    } else {
51        format!("{value:.2} {name}")
52    }
53}
54
55/// Parse `500`, `100MB`, `1.5 GiB`, `2g`. Decimal units are powers of 1000,
56/// `*iB` units powers of 1024. Integer amounts are exact.
57pub fn parse_size(input: &str) -> Result<u64, String> {
58    let input = input.trim();
59    let split = input
60        .find(|c: char| !(c.is_ascii_digit() || c == '.'))
61        .unwrap_or(input.len());
62    let (number, unit) = input.split_at(split);
63    if number.is_empty() {
64        return Err(format!(
65            "{input:?} is not a size: expected a non-negative number such as 500MB"
66        ));
67    }
68    let multiplier: u64 = match unit.trim().to_ascii_lowercase().as_str() {
69        "" | "b" => 1,
70        "k" | "kb" => 1_000,
71        "m" | "mb" => 1_000_000,
72        "g" | "gb" => 1_000_000_000,
73        "t" | "tb" => 1_000_000_000_000,
74        "p" | "pb" => 1_000_000_000_000_000,
75        "kib" => 1 << 10,
76        "mib" => 1 << 20,
77        "gib" => 1 << 30,
78        "tib" => 1 << 40,
79        "pib" => 1 << 50,
80        other => {
81            return Err(format!(
82                "unknown size unit {other:?}: use B, kB, MB, GB, TB, or KiB, MiB, GiB, TiB"
83            ));
84        }
85    };
86    let too_large = || format!("{input:?} is too large");
87    if number.bytes().all(|byte| byte.is_ascii_digit()) {
88        return number
89            .parse::<u64>()
90            .ok()
91            .and_then(|value| value.checked_mul(multiplier))
92            .ok_or_else(too_large);
93    }
94    let value: f64 = number
95        .parse()
96        .map_err(|_| format!("{input:?} is not a size: expected a number such as 1.5GB"))?;
97    let scaled = value * multiplier as f64;
98    if !scaled.is_finite() || scaled >= u64::MAX as f64 {
99        return Err(too_large());
100    }
101    Ok(scaled as u64)
102}
103
104/// Format a count with thousands separators: `300,020`.
105pub fn format_count(count: u64) -> String {
106    let digits = count.to_string();
107    let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
108    for (index, digit) in digits.chars().enumerate() {
109        if index > 0 && (digits.len() - index).is_multiple_of(3) {
110            grouped.push(',');
111        }
112        grouped.push(digit);
113    }
114    grouped
115}
116
117const MINUTE: u64 = 60;
118const HOUR: u64 = 60 * MINUTE;
119const DAY: u64 = 24 * HOUR;
120const MONTH: u64 = 30 * DAY;
121const YEAR: u64 = 365 * DAY;
122
123/// Format an age in its largest whole unit, `45 minutes`, `3 days`,
124/// `11 months`, or in years to one decimal, `1 year`, `2.5 years`. Months
125/// and years have the lengths [`parse_age`] gives them, so a parsed `1y`
126/// formats as `1 year`.
127pub fn format_age(age: Duration) -> String {
128    let secs = age.as_secs();
129    let whole = |count: u64, unit: &str| {
130        let plural = if count == 1 { "" } else { "s" };
131        format!("{count} {unit}{plural}")
132    };
133    match secs {
134        0..MINUTE => whole(secs, "second"),
135        MINUTE..HOUR => whole(secs / MINUTE, "minute"),
136        HOUR..DAY => whole(secs / HOUR, "hour"),
137        DAY..MONTH => whole(secs / DAY, "day"),
138        MONTH..YEAR => whole(secs / MONTH, "month"),
139        _ => match (secs / YEAR, secs % YEAR * 10 / YEAR) {
140            (years, 0) => whole(years, "year"),
141            (years, tenth) => format!("{years}.{tenth} years"),
142        },
143    }
144}
145
146/// Parse `90min`, `12h`, `30d`, `2w`, `6mo`, `1y`. A bare `m` is rejected as
147/// ambiguous between minutes and months.
148pub fn parse_age(input: &str) -> Result<Duration, String> {
149    let lowered = input.trim().to_ascii_lowercase();
150    let split = lowered
151        .find(|c: char| !(c.is_ascii_digit() || c == '.'))
152        .unwrap_or(lowered.len());
153    let (number, unit) = lowered.split_at(split);
154    let seconds = match unit.trim() {
155        "s" | "sec" | "secs" => 1,
156        "min" | "mins" => MINUTE,
157        "h" | "hr" | "hrs" | "hour" | "hours" => HOUR,
158        "d" | "day" | "days" => DAY,
159        "w" | "wk" | "week" | "weeks" => 7 * DAY,
160        "mo" | "month" | "months" => MONTH,
161        "y" | "yr" | "year" | "years" => YEAR,
162        "m" => {
163            return Err(format!(
164                "{input:?} is ambiguous: use \"min\" for minutes or \"mo\" for months"
165            ));
166        }
167        _ => {
168            return Err(format!(
169                "{input:?} is not an age: expected a number and a unit, such as 30d, 6mo or 1y"
170            ));
171        }
172    };
173    let value: f64 = number
174        .parse()
175        .map_err(|_| format!("{input:?} is not an age: expected a number such as 30d"))?;
176    // `try_from_secs_f64` rejects negatives, NaN, infinity and values past
177    // `Duration::MAX` exactly; comparing with `Duration::MAX.as_secs_f64()`
178    // let 2^64 seconds through, because that bound rounds up to it.
179    Duration::try_from_secs_f64(value * seconds as f64)
180        .map_err(|_| format!("{input:?} is out of range"))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn formats_three_significant_digits() {
189        assert_eq!(format_size(0, Units::Si), "0 B");
190        assert_eq!(format_size(999, Units::Si), "999 B");
191        assert_eq!(format_size(1_500, Units::Si), "1.50 kB");
192        assert_eq!(format_size(52_300_000_000, Units::Si), "52.3 GB");
193        assert_eq!(format_size(140_000_000_000, Units::Si), "140 GB");
194        assert_eq!(format_size(999_960, Units::Si), "1.00 MB");
195        assert_eq!(format_size(1023, Units::Binary), "1023 B");
196        assert_eq!(format_size(1 << 30, Units::Binary), "1.00 GiB");
197        assert_eq!(format_size(u64::MAX, Units::Si), "18.4 EB");
198        assert_eq!(format_size(u64::MAX, Units::Binary), "16.0 EiB");
199    }
200
201    #[test]
202    fn groups_counts_by_thousands() {
203        assert_eq!(format_count(0), "0");
204        assert_eq!(format_count(999), "999");
205        assert_eq!(format_count(1_000), "1,000");
206        assert_eq!(format_count(300_020), "300,020");
207        assert_eq!(format_count(u64::MAX), "18,446,744,073,709,551,615");
208    }
209
210    #[test]
211    fn parses_decimal_and_binary_sizes() {
212        assert_eq!(parse_size("500"), Ok(500));
213        assert_eq!(parse_size(" 100MB "), Ok(100_000_000));
214        assert_eq!(parse_size("1.5 gb"), Ok(1_500_000_000));
215        assert_eq!(parse_size("1GiB"), Ok(1 << 30));
216        assert_eq!(parse_size("18446744073709551615"), Ok(u64::MAX));
217        for invalid in [
218            "",
219            "2x",
220            "abc",
221            "-1",
222            "1e400GB",
223            "18446744073709551616",
224            "20000PB",
225        ] {
226            assert!(parse_size(invalid).is_err(), "{invalid}");
227        }
228    }
229
230    #[test]
231    fn parses_ages_and_rejects_ambiguous_minutes() {
232        assert_eq!(parse_age("30d"), Ok(Duration::from_secs(30 * DAY)));
233        assert_eq!(parse_age("6mo"), Ok(Duration::from_secs(180 * DAY)));
234        assert_eq!(parse_age("2W"), Ok(Duration::from_secs(14 * DAY)));
235        assert_eq!(parse_age("90min"), Ok(Duration::from_secs(90 * MINUTE)));
236        assert_eq!(parse_age("1.5h"), Ok(Duration::from_secs(90 * MINUTE)));
237        assert!(parse_age("6m").unwrap_err().contains("ambiguous"));
238        // 18446744073709551616s is 2^64 seconds, just past `Duration::MAX`;
239        // found by fuzzing, it panicked.
240        for invalid in ["", "30", "soon", "-1d", "1e400y", "18446744073709551616s"] {
241            assert!(parse_age(invalid).is_err(), "{invalid}");
242        }
243    }
244
245    #[test]
246    fn formats_ages_in_the_units_they_parse_in() {
247        let secs = Duration::from_secs;
248        assert_eq!(format_age(secs(1)), "1 second");
249        assert_eq!(format_age(secs(30)), "30 seconds");
250        assert_eq!(format_age(secs(2 * MINUTE)), "2 minutes");
251        assert_eq!(format_age(secs(DAY)), "1 day");
252        assert_eq!(format_age(secs(29 * DAY)), "29 days");
253        for (input, expected) in [
254            ("1y", "1 year"),
255            ("6mo", "6 months"),
256            ("1mo", "1 month"),
257            ("2.5y", "2.5 years"),
258            ("2w", "14 days"),
259        ] {
260            assert_eq!(format_age(parse_age(input).unwrap()), expected, "{input}");
261        }
262        assert_eq!(format_age(secs(YEAR - 1)), "12 months");
263        assert_eq!(format_age(Duration::MAX), "584942417355 years");
264    }
265}