1use std::path::Path;
4use std::time::{Duration, SystemTime};
5
6pub 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
21pub 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
28pub fn human_size_parts(bytes: u64) -> (String, &'static str) {
33 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
58pub 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
76pub 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
110pub 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
124pub 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 assert_eq!(human_size_parts(713), ("713".into(), "B"));
202 assert_eq!(human_size_parts(0), ("0".into(), "B"));
203 assert_eq!(human_size_parts(2 * 1024 + 512), ("2.5".into(), "KiB"));
205 assert_eq!(human_size_parts(28 * 1024), ("28".into(), "KiB"));
207 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 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 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}