rust: foundation (inventory, platform, scan, export, textutil, config)

This commit is contained in:
Andy Dixon
2026-10-08 17:08:49 +01:00
parent 8d9344cfef
commit a530bc7a75
15 changed files with 3665 additions and 0 deletions
+368
View File
@@ -0,0 +1,368 @@
//! 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");
}
}