Skip to main content

putzen_cli/caches/
format.rs

1//! Pure formatters: human-readable bytes, durations, and display labels.
2
3use std::path::Path;
4use std::time::{Duration, SystemTime};
5
6/// Render `path` with the user's home directory collapsed to `~/`. Falls
7/// back to the full `display()` form when the path isn't under `home` or
8/// when `home` is `None`.
9pub fn tildify(path: &Path, home: Option<&Path>) -> String {
10    if let Some(home) = home {
11        if let Ok(rest) = path.strip_prefix(home) {
12            if rest.as_os_str().is_empty() {
13                return "~".to_string();
14            }
15            return format!("~/{}", rest.display());
16        }
17    }
18    path.display().to_string()
19}
20
21/// Format a `SystemTime` as an absolute `YYYY-MM-DD` date in the system local zone.
22pub fn human_date(t: SystemTime) -> String {
23    let ts: jiff::Timestamp = t.try_into().unwrap_or(jiff::Timestamp::UNIX_EPOCH);
24    let zoned = ts.to_zoned(jiff::tz::TimeZone::system());
25    zoned.strftime("%Y-%m-%d").to_string()
26}
27
28/// Format `bytes` as `(number, unit)` so callers that stack values can
29/// right-align number and unit in separate sub-columns.  Number is up to
30/// 4 chars (`"1023"`, `"9.9"`, `"999"`); unit is 1 (`"B"`) or 3 (`"KiB"`
31/// … `"TiB"`).
32pub fn human_size_parts(bytes: u64) -> (String, &'static str) {
33    // 1024-based (IEC binary) units, matching the project-wide `HumanReadable`
34    // trait in src/lib.rs. Decimal SI (1000-based) is reserved for human_count.
35    // Single decimal place for values < 10 of a unit; integer for >= 10.
36    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
37    let mut n = bytes as f64;
38    let mut idx = 0;
39    while n >= 1024.0 && idx < UNITS.len() - 1 {
40        n /= 1024.0;
41        idx += 1;
42    }
43    let num = if idx == 0 {
44        bytes.to_string()
45    } else if n < 10.0 {
46        format!("{n:.1}")
47    } else {
48        format!("{n:.0}")
49    };
50    (num, UNITS[idx])
51}
52
53pub fn human_size(bytes: u64) -> String {
54    let (num, unit) = human_size_parts(bytes);
55    format!("{num} {unit}")
56}
57
58/// Truncate `s` to at most `width` display columns, replacing the tail with
59/// `…` when it would otherwise overflow. Width is measured by `chars().count()`
60/// — fine for the cache-folder names we render, which are ASCII-ish in practice
61/// and don't carry wide CJK or grapheme clusters.
62pub fn truncate_with_ellipsis(s: &str, width: usize) -> String {
63    if s.chars().count() <= width {
64        return s.to_string();
65    }
66    if width == 0 {
67        return String::new();
68    }
69    if width == 1 {
70        return "…".to_string();
71    }
72    let head: String = s.chars().take(width - 1).collect();
73    format!("{head}…")
74}
75
76/// Pick `singular` when `n == 1`, `plural` otherwise. Trivial helper, but
77/// having one place beats `if n == 1 { "x" } else { "xs" }` sprinkled across
78/// six call sites — and grep-ing for `pluralize` is easier than chasing
79/// inline ternaries.
80pub fn pluralize<'a>(n: u64, singular: &'a str, plural: &'a str) -> &'a str {
81    if n == 1 {
82        singular
83    } else {
84        plural
85    }
86}
87
88pub fn human_age(cold: Duration) -> String {
89    let secs = cold.as_secs();
90    const MIN: u64 = 60;
91    const HOUR: u64 = 60 * MIN;
92    const DAY: u64 = 24 * HOUR;
93    const MO: u64 = 30 * DAY;
94    const YEAR: u64 = 365 * DAY;
95    if secs >= YEAR {
96        format!("{}y", secs / YEAR)
97    } else if secs >= MO {
98        format!("{}mo", secs / MO)
99    } else if secs >= DAY {
100        format!("{}d", secs / DAY)
101    } else if secs >= HOUR {
102        format!("{}h", secs / HOUR)
103    } else if secs >= MIN {
104        format!("{}m", secs / MIN)
105    } else {
106        format!("{}s", secs)
107    }
108}
109
110/// Format an integer count with `.` thousands separators (European style).
111pub fn human_int(n: u64) -> String {
112    let s = n.to_string();
113    let bytes = s.as_bytes();
114    let mut out = String::with_capacity(s.len() + s.len() / 3);
115    for (i, b) in bytes.iter().enumerate() {
116        if i > 0 && (bytes.len() - i).is_multiple_of(3) {
117            out.push('.');
118        }
119        out.push(*b as char);
120    }
121    out
122}
123
124/// Format a positive floating-point count with k / M / G / T suffixes.
125/// Sub-1000 values render as integers. Used for "impact points" (size × age).
126pub fn human_count(n: f64) -> String {
127    const UNITS: [&str; 5] = ["", "k", "M", "G", "T"];
128    let mut n = n.max(0.0);
129    let mut idx = 0;
130    while n >= 1000.0 && idx < UNITS.len() - 1 {
131        n /= 1000.0;
132        idx += 1;
133    }
134    if idx == 0 {
135        format!("{:.0}", n)
136    } else {
137        format!("{:.1} {}", n, UNITS[idx])
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::path::PathBuf;
145    use std::time::Duration;
146
147    #[test]
148    fn tildify_collapses_home_prefix() {
149        let home = PathBuf::from("/u/sven");
150        let p = PathBuf::from("/u/sven/.cargo/registry");
151        assert_eq!(tildify(&p, Some(&home)), "~/.cargo/registry");
152    }
153
154    #[test]
155    fn tildify_home_itself_renders_as_tilde() {
156        let home = PathBuf::from("/u/sven");
157        assert_eq!(tildify(&home, Some(&home)), "~");
158    }
159
160    #[test]
161    fn tildify_keeps_outside_paths_intact() {
162        let home = PathBuf::from("/u/sven");
163        let p = PathBuf::from("/var/cache/something");
164        assert_eq!(tildify(&p, Some(&home)), "/var/cache/something");
165    }
166
167    #[test]
168    fn tildify_without_home_renders_absolute() {
169        let p = PathBuf::from("/u/sven/.cargo");
170        assert_eq!(tildify(&p, None), "/u/sven/.cargo");
171    }
172
173    #[test]
174    fn truncate_passes_short_strings_through() {
175        assert_eq!(truncate_with_ellipsis("npm", 8), "npm");
176    }
177
178    #[test]
179    fn truncate_replaces_tail_with_ellipsis() {
180        assert_eq!(truncate_with_ellipsis("huggingface-hub", 8), "hugging…");
181    }
182
183    #[test]
184    fn truncate_degenerate_widths() {
185        assert_eq!(truncate_with_ellipsis("abc", 0), "");
186        assert_eq!(truncate_with_ellipsis("abc", 1), "…");
187        assert_eq!(truncate_with_ellipsis("abc", 3), "abc");
188    }
189
190    #[test]
191    fn pluralize_picks_singular_for_one() {
192        assert_eq!(pluralize(1, "folder", "folders"), "folder");
193        assert_eq!(pluralize(0, "folder", "folders"), "folders");
194        assert_eq!(pluralize(2, "folder", "folders"), "folders");
195        assert_eq!(pluralize(47, "entry", "entries"), "entries");
196    }
197
198    #[test]
199    fn human_size_parts_keeps_number_and_unit_separate() {
200        // Bytes branch: number = raw integer, unit = "B".
201        assert_eq!(human_size_parts(713), ("713".into(), "B"));
202        assert_eq!(human_size_parts(0), ("0".into(), "B"));
203        // KiB branch with one decimal under 10.
204        assert_eq!(human_size_parts(2 * 1024 + 512), ("2.5".into(), "KiB"));
205        // KiB branch >= 10 → integer.
206        assert_eq!(human_size_parts(28 * 1024), ("28".into(), "KiB"));
207        // GiB branch.
208        assert_eq!(human_size_parts(3 * 1024u64.pow(3)), ("3.0".into(), "GiB"));
209    }
210
211    #[test]
212    fn human_size_stays_compatible_with_parts_split() {
213        // The convenience wrapper still produces "{num} {unit}".
214        let (n, u) = human_size_parts(28 * 1024);
215        assert_eq!(human_size(28 * 1024), format!("{n} {u}"));
216    }
217
218    #[test]
219    fn human_size_bytes() {
220        assert_eq!(human_size(0), "0 B");
221        assert_eq!(human_size(512), "512 B");
222        assert_eq!(human_size(1023), "1023 B");
223    }
224    #[test]
225    fn human_size_kib() {
226        assert_eq!(human_size(1024), "1.0 KiB");
227        assert_eq!(human_size(1536), "1.5 KiB");
228    }
229    #[test]
230    fn human_size_gib() {
231        assert_eq!(human_size(2_684_354_560), "2.5 GiB");
232    }
233
234    #[test]
235    fn human_age_buckets() {
236        assert_eq!(human_age(Duration::from_secs(30)), "30s");
237        assert_eq!(human_age(Duration::from_secs(90)), "1m");
238        assert_eq!(human_age(Duration::from_secs(3600)), "1h");
239        assert_eq!(human_age(Duration::from_secs(86_400)), "1d");
240        assert_eq!(human_age(Duration::from_secs(7_776_000)), "3mo");
241        assert_eq!(human_age(Duration::from_secs(2 * 365 * 86_400)), "2y");
242    }
243
244    #[test]
245    fn human_date_formats_unix_epoch() {
246        let t = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
247        let s = human_date(t);
248        // 2023-11-14 in UTC; ok if rendered local — tolerate ±1 day across zones.
249        assert!(s.starts_with("2023-") || s.starts_with("2024-"), "got: {s}");
250        assert_eq!(s.len(), 10, "expected YYYY-MM-DD, got: {s}");
251    }
252
253    #[test]
254    fn human_int_thousands_dotted() {
255        assert_eq!(human_int(0), "0");
256        assert_eq!(human_int(999), "999");
257        assert_eq!(human_int(1_000), "1.000");
258        assert_eq!(human_int(47_218), "47.218");
259        assert_eq!(human_int(1_000_000), "1.000.000");
260    }
261
262    #[test]
263    fn human_count_buckets() {
264        assert_eq!(human_count(0.0), "0");
265        assert_eq!(human_count(42.4), "42");
266        assert_eq!(human_count(999.0), "999");
267        assert_eq!(human_count(1_000.0), "1.0 k");
268        assert_eq!(human_count(1_100.0), "1.1 k");
269        assert_eq!(human_count(2_500_000.0), "2.5 M");
270        assert_eq!(human_count(1.5e9), "1.5 G");
271    }
272}