Skip to main content

proc_cli/ui/
format.rs

1//! Shared formatting utilities used across commands and output
2//!
3//! Provides common string formatting, truncation, and colorization.
4
5use crate::core::ProcessStatus;
6use colored::*;
7use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
8
9/// Format a duration in seconds to a human-readable string.
10///
11/// Examples: "45s", "3m 12s", "2h 15m", "1d 6h"
12pub 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
24/// Uptime as a trailing label for one-line process views (tree, why):
25/// `"  up 3m 12s"`, or empty when unknown.
26pub 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
32/// Return "es" for plural process counts, "" for singular.
33///
34/// Usage: `format!("{} process{}", n, plural(n))` → "1 process" or "3 processes"
35pub fn plural(n: usize) -> &'static str {
36    if n == 1 {
37        ""
38    } else {
39        "es"
40    }
41}
42
43/// Truncate `s` to `max_width` terminal columns, appending `…` when cut.
44///
45/// Counts display width rather than bytes, so CJK and emoji (two columns each)
46/// stay aligned, and a multi-byte character is never split.
47pub 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    // One column is reserved for the ellipsis.
55    let mut out = take_width(s, max_width - 1);
56    out.push('…');
57    out
58}
59
60/// Truncate a path to `max_width` terminal columns, eliding the middle.
61///
62/// The tail is what differs between rows (`.next/standalone` vs `.next/server`),
63/// so it keeps the larger share and the root stays visible:
64/// `/Users/zee/…/maliqe-website/.next/standalone`.
65pub 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            // Snap the head to a path separator so it reads as a real prefix
75            // ("/Users/") rather than an arbitrary cut ("/Use").
76            let head = take_width(path, budget / 3);
77            let head = match head.rfind('/') {
78                Some(i) => &head[..=i],
79                None => "",
80            };
81            // Too narrow for a prefix worth printing: spend it all on the tail,
82            // which is the part that differs between rows.
83            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
97/// Longest prefix of `s` fitting in `max_width` columns, cut on a char boundary.
98fn 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
112/// Longest suffix of `s` fitting in `max_width` columns, cut on a char boundary.
113fn 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
127/// What the three flexible process-table columns may use at a given terminal
128/// width, once the fixed columns are accounted for.
129pub struct ProcessTableBudget {
130    /// Column width for PID, including comfy-table's padding.
131    pub pid: usize,
132    /// Column width for CPU%.
133    pub cpu: usize,
134    /// Column width for MEM.
135    pub mem: usize,
136    /// Column width for UPTIME.
137    pub uptime: usize,
138    /// Column width for STATUS.
139    pub status: usize,
140    /// Content width for DIR.
141    pub dir: usize,
142    /// Content width for NAME.
143    pub name: usize,
144    /// Content width for ARGS.
145    pub args: usize,
146    /// UPTIME only fits next to a readable ARGS column on wider terminals.
147    pub show_uptime: bool,
148}
149
150/// Column budget for a process table rendered at `width` terminal columns.
151///
152/// Both process tables — `ui::output` and `commands::watch` — size their
153/// columns from this. They used to carry separate copies of the same layout,
154/// which is exactly how `watch` kept wrapping after the other table was fixed.
155///
156/// Pre-truncating each flexible column to its share is what keeps one process
157/// on one line: comfy-table wraps any cell that does not fit, and a wrapped
158/// cell destroys the alignment that makes a table readable.
159pub fn process_table_budget(width: u16) -> ProcessTableBudget {
160    // Fixed columns: content plus comfy-table's 1-column padding on each side.
161    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    // Each flexible column renders as its content width plus comfy-table's
167    // 2 columns of padding, so reserve those 6 up front.
168    const FLEX_PADDING: usize = 6;
169    // Measured slack: with arrangement disabled the fixed columns render a few
170    // columns wider than their constraints, and a line exactly as wide as the
171    // terminal wraps in some emulators. Verified against rendered output at
172    // 80/100/120/160/200 columns.
173    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
197/// Format memory in MB to a human-readable string with adaptive units.
198///
199/// Examples: "512KB", "6.0MB", "3.0GB"
200pub 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
210/// Colorize a process status string based on the status variant.
211pub 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    /// Strings that previously panicked: byte slicing cut inside a multi-byte char.
227    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        // Exactly at the limit is not truncated
287        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        // 10 CJK chars = 20 columns; at max 10 we must emit at most 10 columns
332        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        // Never below the floor, however small the terminal
399        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        // At 120 columns the three flexible columns share what the fixed ones leave
403        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}