1use crate::core::ProcessStatus;
6use colored::*;
7use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
8
9pub fn format_duration(secs: u64) -> String {
13 if secs < 60 {
14 format!("{}s", secs)
15 } else if secs < 3600 {
16 format!("{}m {}s", secs / 60, secs % 60)
17 } else if secs < 86400 {
18 format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
19 } else {
20 format!("{}d {}h", secs / 86400, (secs % 86400) / 3600)
21 }
22}
23
24pub fn uptime_label(uptime_secs: Option<u64>) -> String {
27 uptime_secs
28 .map(|secs| format!(" up {}", format_duration(secs)))
29 .unwrap_or_default()
30}
31
32pub fn plural(n: usize) -> &'static str {
36 if n == 1 {
37 ""
38 } else {
39 "es"
40 }
41}
42
43pub fn truncate_string(s: &str, max_width: usize) -> String {
48 if s.width() <= max_width {
49 return s.to_string();
50 }
51 if max_width == 0 {
52 return String::new();
53 }
54 let mut out = take_width(s, max_width - 1);
56 out.push('…');
57 out
58}
59
60pub fn truncate_path(path: &str, max_width: usize) -> String {
66 if path.width() <= max_width {
67 return path.to_string();
68 }
69 match max_width {
70 0 => String::new(),
71 1 => "…".to_string(),
72 _ => {
73 let budget = max_width - 1;
74 let head = take_width(path, budget / 3);
77 let head = match head.rfind('/') {
78 Some(i) => &head[..=i],
79 None => "",
80 };
81 if head.width() < 2 {
84 let mut out = String::from("…");
85 out.push_str(&take_width_from_end(path, budget));
86 return out;
87 }
88 let tail_width = budget - head.width();
89 let mut out = String::from(head);
90 out.push('…');
91 out.push_str(&take_width_from_end(path, tail_width));
92 out
93 }
94 }
95}
96
97fn take_width(s: &str, max_width: usize) -> String {
99 let mut out = String::new();
100 let mut used = 0;
101 for ch in s.chars() {
102 let w = ch.width().unwrap_or(0);
103 if used + w > max_width {
104 break;
105 }
106 out.push(ch);
107 used += w;
108 }
109 out
110}
111
112fn take_width_from_end(s: &str, max_width: usize) -> String {
114 let mut chars = Vec::new();
115 let mut used = 0;
116 for ch in s.chars().rev() {
117 let w = ch.width().unwrap_or(0);
118 if used + w > max_width {
119 break;
120 }
121 chars.push(ch);
122 used += w;
123 }
124 chars.iter().rev().collect()
125}
126
127pub struct ProcessTableBudget {
130 pub pid: usize,
132 pub cpu: usize,
134 pub mem: usize,
136 pub uptime: usize,
138 pub status: usize,
140 pub dir: usize,
142 pub name: usize,
144 pub args: usize,
146 pub show_uptime: bool,
148}
149
150pub fn process_table_budget(width: u16) -> ProcessTableBudget {
160 const PID_W: usize = 8;
162 const CPU_W: usize = 8;
163 const MEM_W: usize = 11;
164 const UPTIME_W: usize = 10;
165 const STATUS_W: usize = 12;
166 const FLEX_PADDING: usize = 6;
169 const SAFETY: usize = 7;
174
175 let show_uptime = width >= 100;
176 let fixed = PID_W + CPU_W + MEM_W + STATUS_W + if show_uptime { UPTIME_W } else { 0 };
177 let avail = (width as usize)
178 .saturating_sub(fixed + FLEX_PADDING + SAFETY)
179 .max(24);
180 let name = (avail / 4).clamp(8, 16);
181 let rest = avail - name;
182 let args = (rest / 2).max(12);
183 let dir = rest - args;
184 ProcessTableBudget {
185 pid: PID_W,
186 cpu: CPU_W,
187 mem: MEM_W,
188 uptime: UPTIME_W,
189 status: STATUS_W,
190 dir,
191 name,
192 args,
193 show_uptime,
194 }
195}
196
197pub fn format_memory(memory_mb: f64) -> String {
201 if memory_mb < 1.0 {
202 format!("{:.0}KB", memory_mb * 1000.0)
203 } else if memory_mb < 1000.0 {
204 format!("{:.1}MB", memory_mb)
205 } else {
206 format!("{:.1}GB", memory_mb / 1000.0)
207 }
208}
209
210pub fn colorize_status(status: &ProcessStatus, status_str: &str) -> ColoredString {
212 match status {
213 ProcessStatus::Running => status_str.green(),
214 ProcessStatus::Sleeping => status_str.blue(),
215 ProcessStatus::Stopped => status_str.yellow(),
216 ProcessStatus::Zombie => status_str.red(),
217 _ => status_str.white(),
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use unicode_width::UnicodeWidthStr;
225
226 const NON_ASCII: &[&str] = &[
228 "éééééééééééééééééééééééééééééééééééééééé",
229 "café-ünïcødé-très-lông-serveur",
230 "/Users/zee/Sites/café-website/.next/standalone/ünïcødé",
231 "日本語のプロセス名前テストですこれは長い",
232 "🚀🚀🚀 deploy 🚀🚀🚀 a very long emoji argument line 🚀🚀🚀",
233 "Ωmega-Δelta-Σigma-Πi-Φphi-Ψpsi-very-long-name",
234 ];
235
236 #[test]
237 fn truncate_string_never_panics_at_any_width() {
238 for s in NON_ASCII {
239 for max in 0..=s.len() + 2 {
240 let _ = truncate_string(s, max);
241 }
242 }
243 }
244
245 #[test]
246 fn truncate_path_never_panics_at_any_width() {
247 for s in NON_ASCII {
248 for max in 0..=s.len() + 2 {
249 let _ = truncate_path(s, max);
250 }
251 }
252 }
253
254 #[test]
255 fn truncate_string_respects_display_width() {
256 for s in NON_ASCII {
257 for max in 1..=40 {
258 let out = truncate_string(s, max);
259 let width = out.width();
260 assert!(
261 width <= max,
262 "width {width} exceeded max {max} for {s:?} -> {out:?}"
263 );
264 }
265 }
266 }
267
268 #[test]
269 fn truncate_path_respects_display_width() {
270 for s in NON_ASCII {
271 for max in 1..=40 {
272 let out = truncate_path(s, max);
273 let width = out.width();
274 assert!(
275 width <= max,
276 "width {width} exceeded max {max} for {s:?} -> {out:?}"
277 );
278 }
279 }
280 }
281
282 #[test]
283 fn short_values_pass_through_unchanged() {
284 assert_eq!(truncate_string("node", 20), "node");
285 assert_eq!(truncate_path("/usr/bin", 20), "/usr/bin");
286 assert_eq!(truncate_string("abcde", 5), "abcde");
288 }
289
290 #[test]
291 fn truncate_path_elides_the_middle_keeping_head_and_tail() {
292 let p = "/Users/zee/Sites/maliqe-website/.next/standalone";
293 let out = truncate_path(p, 30);
294 assert!(out.contains('…'), "expected a middle ellipsis in {:?}", out);
295 assert!(out.starts_with('/'), "head should survive: {:?}", out);
296 assert!(
297 out.ends_with("standalone"),
298 "distinguishing tail should survive: {:?}",
299 out
300 );
301 }
302
303 #[test]
304 fn truncate_path_falls_back_to_tail_only_when_too_narrow() {
305 let p = "/Users/zee/Sites/weirdpredictionmarkets";
306 let out = truncate_path(p, 14);
307 assert!(out.width() <= 14, "{:?} is {} wide", out, out.width());
308 assert!(
309 out.starts_with('…'),
310 "expected tail-only form, got {:?}",
311 out
312 );
313 assert!(out.ends_with("markets"), "tail should survive: {:?}", out);
314 }
315
316 #[test]
317 fn truncate_path_head_snaps_to_a_separator() {
318 let p = "/Users/zee/Sites/maliqe-website/.next/standalone";
319 let out = truncate_path(p, 34);
320 assert!(out.width() <= 34, "{:?} is {} wide", out, out.width());
321 let head = out.split('…').next().unwrap();
322 assert!(
323 head.ends_with('/'),
324 "head {:?} should end at a separator",
325 head
326 );
327 }
328
329 #[test]
330 fn cjk_counts_as_two_columns() {
331 let s = "日本語のプロセス名前";
333 let out = truncate_string(s, 10);
334 assert!(out.width() <= 10, "got width {} for {:?}", out.width(), out);
335 }
336
337 #[test]
338 fn durations_use_the_two_largest_units() {
339 for (secs, shown) in [
340 (0, "0s"),
341 (59, "59s"),
342 (60, "1m 0s"),
343 (3599, "59m 59s"),
344 (3600, "1h 0m"),
345 (86399, "23h 59m"),
346 (86400, "1d 0h"),
347 (90000 + 3600 * 5, "1d 6h"),
348 ] {
349 assert_eq!(format_duration(secs), shown, "{secs} seconds");
350 }
351 }
352
353 #[test]
354 fn the_uptime_label_is_empty_when_the_uptime_is_unknown() {
355 assert_eq!(uptime_label(None), "");
356 assert_eq!(uptime_label(Some(192)), " up 3m 12s");
357 }
358
359 #[test]
360 fn only_one_process_is_singular() {
361 assert_eq!(plural(0), "es");
362 assert_eq!(plural(1), "");
363 assert_eq!(plural(2), "es");
364 }
365
366 #[test]
367 fn memory_switches_unit_at_a_megabyte_and_at_a_gigabyte() {
368 assert_eq!(format_memory(0.0), "0KB");
369 assert_eq!(format_memory(0.5), "500KB");
370 assert_eq!(format_memory(1.0), "1.0MB");
371 assert_eq!(format_memory(999.94), "999.9MB");
372 assert_eq!(format_memory(1000.0), "1.0GB");
373 assert_eq!(format_memory(3060.0), "3.1GB");
374 }
375
376 #[test]
377 fn each_status_has_its_own_colour() {
378 for (status, colour) in [
379 (ProcessStatus::Running, Color::Green),
380 (ProcessStatus::Sleeping, Color::Blue),
381 (ProcessStatus::Stopped, Color::Yellow),
382 (ProcessStatus::Zombie, Color::Red),
383 (ProcessStatus::Dead, Color::White),
384 (ProcessStatus::Unknown, Color::White),
385 ] {
386 let coloured = colorize_status(&status, "word");
387 assert_eq!(coloured.fgcolor, Some(colour), "{status:?}");
388 assert_eq!(coloured.input, "word");
389 }
390 }
391
392 #[test]
393 fn the_budget_adds_uptime_from_100_columns_and_always_fits_the_minimum() {
394 let narrow = process_table_budget(99);
395 assert!(!narrow.show_uptime);
396 let wide = process_table_budget(100);
397 assert!(wide.show_uptime);
398 let tiny = process_table_budget(10);
400 assert_eq!(tiny.dir + tiny.name + tiny.args, 24);
401 assert_eq!((tiny.name, tiny.args, tiny.dir), (8, 12, 4));
402 let b = process_table_budget(120);
404 assert_eq!((b.name, b.args, b.dir), (14, 22, 22));
405 assert_eq!(
406 (b.pid, b.cpu, b.mem, b.uptime, b.status),
407 (8, 8, 11, 10, 12)
408 );
409 }
410}