369 lines
11 KiB
Rust
369 lines
11 KiB
Rust
//! Display helpers: sanitising untrusted strings for the terminal,
|
||
//! width-aware truncation, and human-readable units.
|
||
|
||
use std::fmt::Write;
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::time::Duration;
|
||
use unicode_segmentation::UnicodeSegmentation;
|
||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||
|
||
/// Makes an untrusted string (a filename, snapshot data) safe to write to a
|
||
/// terminal. Control characters (C0, DEL, C1) and bidi overrides become
|
||
/// visible escapes; invalid UTF-8 becomes U+FFFD. Keep the original bytes
|
||
/// for filesystem operations.
|
||
pub fn sanitize_bytes(s: &[u8]) -> String {
|
||
if s.iter().all(|&c| (0x20..0x7f).contains(&c)) {
|
||
// SAFETY-free: printable ASCII is valid UTF-8.
|
||
return String::from_utf8(s.to_vec()).unwrap();
|
||
}
|
||
let mut b = String::with_capacity(s.len() + 8);
|
||
for chunk in s.utf8_chunks() {
|
||
for r in chunk.valid().chars() {
|
||
let c = r as u32;
|
||
if c < 0x20 || c == 0x7f || (0x80..=0x9f).contains(&c) {
|
||
let _ = write!(b, "\\x{c:02x}");
|
||
} else if is_bidi_control(c) {
|
||
let _ = write!(b, "\\u{c:04x}");
|
||
} else {
|
||
b.push(r);
|
||
}
|
||
}
|
||
for _ in chunk.invalid() {
|
||
b.push('\u{FFFD}');
|
||
}
|
||
}
|
||
b
|
||
}
|
||
|
||
/// sanitize_bytes for strings.
|
||
pub fn sanitize(s: &str) -> String {
|
||
sanitize_bytes(s.as_bytes())
|
||
}
|
||
|
||
fn is_bidi_control(r: u32) -> bool {
|
||
(0x202a..=0x202e).contains(&r) || (0x2066..=0x2069).contains(&r) || r == 0x200e || r == 0x200f || r == 0x061c
|
||
}
|
||
|
||
/// Calls f for each grapheme cluster of s with its display width. Clusters
|
||
/// whose width terminals disagree on (per-char wcwidth versus grapheme
|
||
/// width, e.g. emoji ZWJ sequences) are replaced by U+FFFD with width 1, and
|
||
/// zero-width clusters are dropped, so measuring and drawing always agree
|
||
/// with the terminal. f returns false to stop.
|
||
pub fn clusters(s: &str, mut f: impl FnMut(&str, usize) -> bool) {
|
||
for c in s.graphemes(true) {
|
||
let mut chars = c.chars();
|
||
let first = chars.next().unwrap();
|
||
let w = if chars.next().is_none() { first.width().unwrap_or(0) } else { c.width() };
|
||
if w == 0 {
|
||
continue;
|
||
}
|
||
let ok = if c.len() > 1 && c.chars().map(|r| r.width().unwrap_or(0)).sum::<usize>() != w {
|
||
f("\u{FFFD}", 1)
|
||
} else {
|
||
f(c, w)
|
||
};
|
||
if !ok {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Display width of s in terminal cells.
|
||
pub fn width(s: &str) -> usize {
|
||
if s.bytes().all(|c| (0x20..0x7f).contains(&c)) {
|
||
return s.len();
|
||
}
|
||
let mut n = 0;
|
||
clusters(s, |_, w| {
|
||
n += w;
|
||
true
|
||
});
|
||
n
|
||
}
|
||
|
||
/// Clips s to at most w cells, appending "…" if anything was cut.
|
||
pub fn truncate(s: &str, w: usize) -> String {
|
||
if w == 0 {
|
||
return String::new();
|
||
}
|
||
if width(s) <= w {
|
||
return s.to_string();
|
||
}
|
||
clip(s, w - 1) + "…"
|
||
}
|
||
|
||
/// Clips from the left, prefixing "…" (for paths and breadcrumbs where the
|
||
/// tail is the informative part).
|
||
pub fn truncate_left(s: &str, w: usize) -> String {
|
||
if w == 0 {
|
||
return String::new();
|
||
}
|
||
let total = width(s);
|
||
if total <= w {
|
||
return s.to_string();
|
||
}
|
||
let mut skip = (total - (w - 1)) as isize;
|
||
let mut b = String::from("…");
|
||
clusters(s, |c, cw| {
|
||
if skip > 0 {
|
||
skip -= cw as isize;
|
||
if skip < 0 {
|
||
// A wide cluster overshot by one cell; pad to keep width exact.
|
||
b.push_str(&" ".repeat((-skip) as usize));
|
||
}
|
||
return true;
|
||
}
|
||
b.push_str(c);
|
||
true
|
||
});
|
||
b
|
||
}
|
||
|
||
/// The longest prefix of s that fits in w cells.
|
||
fn clip(s: &str, w: usize) -> String {
|
||
let mut b = String::new();
|
||
let mut used = 0;
|
||
clusters(s, |c, cw| {
|
||
if used + cw > w {
|
||
return false;
|
||
}
|
||
used += cw;
|
||
b.push_str(c);
|
||
true
|
||
});
|
||
b
|
||
}
|
||
|
||
/// Pads s with spaces to exactly w cells (truncating if longer).
|
||
pub fn pad_right(s: &str, w: usize) -> String {
|
||
let mut s = truncate(s, w);
|
||
let sw = width(&s);
|
||
if sw < w {
|
||
s.push_str(&" ".repeat(w - sw));
|
||
}
|
||
s
|
||
}
|
||
|
||
/// Right-aligns s in w cells.
|
||
pub fn pad_left(s: &str, w: usize) -> String {
|
||
let s = truncate(s, w);
|
||
let sw = width(&s);
|
||
if sw < w {
|
||
return " ".repeat(w - sw) + &s;
|
||
}
|
||
s
|
||
}
|
||
|
||
static SI: AtomicBool = AtomicBool::new(false);
|
||
|
||
/// Selects decimal units (kB, MB, …) instead of IEC (KiB, MiB, …). Set once
|
||
/// at start-up.
|
||
pub fn set_si(v: bool) {
|
||
SI.store(v, Ordering::Relaxed);
|
||
}
|
||
|
||
const IEC_UNITS: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
|
||
const SI_UNITS: [&str; 7] = ["B", "kB", "MB", "GB", "TB", "PB", "EB"];
|
||
|
||
fn scaled(n: i64) -> (f64, &'static str) {
|
||
let (base, units) = if SI.load(Ordering::Relaxed) { (1000.0, &SI_UNITS) } else { (1024.0, &IEC_UNITS) };
|
||
let neg = n < 0;
|
||
let mut v = (n as f64).abs();
|
||
let mut i = 0;
|
||
while v >= base && i < units.len() - 1 {
|
||
v /= base;
|
||
i += 1;
|
||
}
|
||
(if neg { -v } else { v }, units[i])
|
||
}
|
||
|
||
fn num(v: f64, unit: &str) -> String {
|
||
let a = v.abs();
|
||
if unit == "B" {
|
||
format!("{v:.0}")
|
||
} else if a < 10.0 {
|
||
format!("{v:.2}")
|
||
} else if a < 100.0 {
|
||
format!("{v:.1}")
|
||
} else {
|
||
format!("{v:.0}")
|
||
}
|
||
}
|
||
|
||
/// Formats a byte count, e.g. "86.4 GiB".
|
||
pub fn size(n: i64) -> String {
|
||
let (v, u) = scaled(n);
|
||
num(v, u) + " " + u
|
||
}
|
||
|
||
/// Formats a byte count in at most ~7 cells, e.g. "86GiB".
|
||
pub fn size_compact(n: i64) -> String {
|
||
let (v, u) = scaled(n);
|
||
if u != "B" && v.abs() < 10.0 {
|
||
format!("{v:.1}{u}")
|
||
} else {
|
||
format!("{v:.0}{u}")
|
||
}
|
||
}
|
||
|
||
/// Formats a delta with an explicit sign.
|
||
pub fn signed_size(n: i64) -> String {
|
||
match n {
|
||
n if n > 0 => "+".to_string() + &size(n),
|
||
n if n < 0 => "-".to_string() + &size(n.saturating_neg()),
|
||
_ => "±0 B".to_string(),
|
||
}
|
||
}
|
||
|
||
/// Formats an integer with thousands separators.
|
||
pub fn count(n: i64) -> String {
|
||
let s = n.unsigned_abs().to_string();
|
||
let mut out = String::with_capacity(s.len() + s.len() / 3 + 1);
|
||
if n < 0 {
|
||
out.push('-');
|
||
}
|
||
for (i, c) in s.chars().enumerate() {
|
||
if i > 0 && (s.len() - i) % 3 == 0 {
|
||
out.push(',');
|
||
}
|
||
out.push(c);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Formats an elapsed time as mm:ss or h:mm:ss.
|
||
pub fn duration(d: Duration) -> String {
|
||
let s = d.as_secs();
|
||
if s >= 3600 {
|
||
format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60)
|
||
} else {
|
||
format!("{:02}:{:02}", s / 60, s % 60)
|
||
}
|
||
}
|
||
|
||
/// Formats part/whole as a percentage.
|
||
pub fn percent(part: i64, whole: i64) -> String {
|
||
if whole <= 0 {
|
||
return "–".to_string();
|
||
}
|
||
let p = part as f64 * 100.0 / whole as f64;
|
||
if p < 10.0 {
|
||
format!("{p:.1}%")
|
||
} else {
|
||
format!("{p:.0}%")
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn sanitize_cases() {
|
||
let cases: [(&[u8], &str); 8] = [
|
||
(b"plain.txt", "plain.txt"),
|
||
(b"evil\x1b[2Jname", r"evil\x1b[2Jname"),
|
||
(b"bell\x07", r"bell\x07"),
|
||
("c1\u{009b}x".as_bytes(), r"c1\x9bx"),
|
||
(b"bad\xffutf8", "bad\u{FFFD}utf8"),
|
||
("rtl\u{202e}gnp.exe".as_bytes(), "rtl\\u202egnp.exe"),
|
||
("日本語".as_bytes(), "日本語"),
|
||
(b"tab\tnew\nline\rdel\x7f", r"tab\x09new\x0aline\x0ddel\x7f"),
|
||
];
|
||
for (input, want) in cases {
|
||
let got = sanitize_bytes(input);
|
||
assert_eq!(got, want);
|
||
assert!(!got.contains(['\x1b', '\x07', '\u{009b}']));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn truncation() {
|
||
assert_eq!(truncate("hello world", 5), "hell…");
|
||
assert!(width(&truncate("日本語テキスト", 5)) <= 5);
|
||
assert_eq!(truncate("abc", 3), "abc");
|
||
assert_eq!(truncate("abc", 0), "");
|
||
let got = truncate_left("/a/very/long/path", 8);
|
||
assert!(width(&got) == 8 && got.ends_with("path"), "{got}");
|
||
assert_eq!(width(&truncate_left("ab日本語", 4)), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn sizes() {
|
||
for (n, want) in [(0, "0 B"), (1023, "1023 B"), (1024, "1.00 KiB"), (1536, "1.50 KiB"), (181 << 30, "181 GiB"), (1 << 62, "4.00 EiB")] {
|
||
assert_eq!(size(n), want);
|
||
}
|
||
assert_eq!(count(2184392), "2,184,392");
|
||
assert_eq!(signed_size(-2048), "-2.00 KiB");
|
||
}
|
||
|
||
#[test]
|
||
fn clusters_agree_with_width() {
|
||
for s in ["abc", "日本", "é", "👨👩👧", "🎉x", "\u{200b}"] {
|
||
let mut n = 0;
|
||
clusters(s, |_, w| {
|
||
n += w;
|
||
true
|
||
});
|
||
assert_eq!(n, width(s), "{s:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Formats unix nanoseconds as an RFC 3339 UTC timestamp, e.g.
|
||
/// "2026-10-08T09:30:00Z".
|
||
pub fn rfc3339_utc(unix_nanos: i64) -> String {
|
||
let secs = unix_nanos.div_euclid(1_000_000_000);
|
||
let (days, rem) = (secs.div_euclid(86400), secs.rem_euclid(86400));
|
||
// Civil-from-days (Howard Hinnant).
|
||
let z = days + 719468;
|
||
let era = z.div_euclid(146097);
|
||
let doe = z - era * 146097;
|
||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||
let mp = (5 * doy + 2) / 153;
|
||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||
let y = yoe + era * 400 + (m <= 2) as i64;
|
||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem / 60 % 60, rem % 60)
|
||
}
|
||
|
||
/// Formats a duration like Go's time.Duration.String after rounding to
|
||
/// milliseconds, e.g. "1.234s", "250ms", "2m3.5s".
|
||
pub fn go_duration(d: Duration) -> String {
|
||
let ms = (d.as_nanos() + 500_000) / 1_000_000;
|
||
if ms == 0 {
|
||
return "0s".into();
|
||
}
|
||
if ms < 1000 {
|
||
return format!("{ms}ms");
|
||
}
|
||
let (h, m, s) = (ms / 3_600_000, ms / 60_000 % 60, ms % 60_000);
|
||
let mut secs = format!("{}.{:03}", s / 1000, s % 1000);
|
||
while secs.ends_with('0') {
|
||
secs.pop();
|
||
}
|
||
if secs.ends_with('.') {
|
||
secs.pop();
|
||
}
|
||
match (h, m) {
|
||
(0, 0) => format!("{secs}s"),
|
||
(0, m) => format!("{m}m{secs}s"),
|
||
(h, m) => format!("{h}h{m}m{secs}s"),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod time_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn formats() {
|
||
assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z");
|
||
assert_eq!(rfc3339_utc(1_791_456_000 * 1_000_000_000), "2026-10-08T10:40:00Z");
|
||
assert_eq!(go_duration(Duration::from_micros(1_234_400)), "1.234s");
|
||
assert_eq!(go_duration(Duration::from_millis(250)), "250ms");
|
||
assert_eq!(go_duration(Duration::from_millis(123_500)), "2m3.5s");
|
||
}
|
||
}
|