1use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum Units {
11 #[default]
13 Si,
14 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
27pub 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 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
55pub 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
104pub 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
123pub 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
146pub 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 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 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}