use std::path::Path;
use std::time::{Duration, SystemTime};
pub fn tildify(path: &Path, home: Option<&Path>) -> String {
if let Some(home) = home {
if let Ok(rest) = path.strip_prefix(home) {
if rest.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", rest.display());
}
}
path.display().to_string()
}
pub fn human_date(t: SystemTime) -> String {
let ts: jiff::Timestamp = t.try_into().unwrap_or(jiff::Timestamp::UNIX_EPOCH);
let zoned = ts.to_zoned(jiff::tz::TimeZone::system());
zoned.strftime("%Y-%m-%d").to_string()
}
pub fn human_size_parts(bytes: u64) -> (String, &'static str) {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut n = bytes as f64;
let mut idx = 0;
while n >= 1024.0 && idx < UNITS.len() - 1 {
n /= 1024.0;
idx += 1;
}
let num = if idx == 0 {
bytes.to_string()
} else if n < 10.0 {
format!("{n:.1}")
} else {
format!("{n:.0}")
};
(num, UNITS[idx])
}
pub fn human_size(bytes: u64) -> String {
let (num, unit) = human_size_parts(bytes);
format!("{num} {unit}")
}
pub fn truncate_with_ellipsis(s: &str, width: usize) -> String {
if s.chars().count() <= width {
return s.to_string();
}
if width == 0 {
return String::new();
}
if width == 1 {
return "…".to_string();
}
let head: String = s.chars().take(width - 1).collect();
format!("{head}…")
}
pub fn pluralize<'a>(n: u64, singular: &'a str, plural: &'a str) -> &'a str {
if n == 1 {
singular
} else {
plural
}
}
pub fn human_age(cold: Duration) -> String {
let secs = cold.as_secs();
const MIN: u64 = 60;
const HOUR: u64 = 60 * MIN;
const DAY: u64 = 24 * HOUR;
const MO: u64 = 30 * DAY;
const YEAR: u64 = 365 * DAY;
if secs >= YEAR {
format!("{}y", secs / YEAR)
} else if secs >= MO {
format!("{}mo", secs / MO)
} else if secs >= DAY {
format!("{}d", secs / DAY)
} else if secs >= HOUR {
format!("{}h", secs / HOUR)
} else if secs >= MIN {
format!("{}m", secs / MIN)
} else {
format!("{}s", secs)
}
}
pub fn human_int(n: u64) -> String {
let s = n.to_string();
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (bytes.len() - i).is_multiple_of(3) {
out.push('.');
}
out.push(*b as char);
}
out
}
pub fn human_count(n: f64) -> String {
const UNITS: [&str; 5] = ["", "k", "M", "G", "T"];
let mut n = n.max(0.0);
let mut idx = 0;
while n >= 1000.0 && idx < UNITS.len() - 1 {
n /= 1000.0;
idx += 1;
}
if idx == 0 {
format!("{:.0}", n)
} else {
format!("{:.1} {}", n, UNITS[idx])
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::Duration;
#[test]
fn tildify_collapses_home_prefix() {
let home = PathBuf::from("/u/sven");
let p = PathBuf::from("/u/sven/.cargo/registry");
assert_eq!(tildify(&p, Some(&home)), "~/.cargo/registry");
}
#[test]
fn tildify_home_itself_renders_as_tilde() {
let home = PathBuf::from("/u/sven");
assert_eq!(tildify(&home, Some(&home)), "~");
}
#[test]
fn tildify_keeps_outside_paths_intact() {
let home = PathBuf::from("/u/sven");
let p = PathBuf::from("/var/cache/something");
assert_eq!(tildify(&p, Some(&home)), "/var/cache/something");
}
#[test]
fn tildify_without_home_renders_absolute() {
let p = PathBuf::from("/u/sven/.cargo");
assert_eq!(tildify(&p, None), "/u/sven/.cargo");
}
#[test]
fn truncate_passes_short_strings_through() {
assert_eq!(truncate_with_ellipsis("npm", 8), "npm");
}
#[test]
fn truncate_replaces_tail_with_ellipsis() {
assert_eq!(truncate_with_ellipsis("huggingface-hub", 8), "hugging…");
}
#[test]
fn truncate_degenerate_widths() {
assert_eq!(truncate_with_ellipsis("abc", 0), "");
assert_eq!(truncate_with_ellipsis("abc", 1), "…");
assert_eq!(truncate_with_ellipsis("abc", 3), "abc");
}
#[test]
fn pluralize_picks_singular_for_one() {
assert_eq!(pluralize(1, "folder", "folders"), "folder");
assert_eq!(pluralize(0, "folder", "folders"), "folders");
assert_eq!(pluralize(2, "folder", "folders"), "folders");
assert_eq!(pluralize(47, "entry", "entries"), "entries");
}
#[test]
fn human_size_parts_keeps_number_and_unit_separate() {
assert_eq!(human_size_parts(713), ("713".into(), "B"));
assert_eq!(human_size_parts(0), ("0".into(), "B"));
assert_eq!(human_size_parts(2 * 1024 + 512), ("2.5".into(), "KiB"));
assert_eq!(human_size_parts(28 * 1024), ("28".into(), "KiB"));
assert_eq!(human_size_parts(3 * 1024u64.pow(3)), ("3.0".into(), "GiB"));
}
#[test]
fn human_size_stays_compatible_with_parts_split() {
let (n, u) = human_size_parts(28 * 1024);
assert_eq!(human_size(28 * 1024), format!("{n} {u}"));
}
#[test]
fn human_size_bytes() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(512), "512 B");
assert_eq!(human_size(1023), "1023 B");
}
#[test]
fn human_size_kib() {
assert_eq!(human_size(1024), "1.0 KiB");
assert_eq!(human_size(1536), "1.5 KiB");
}
#[test]
fn human_size_gib() {
assert_eq!(human_size(2_684_354_560), "2.5 GiB");
}
#[test]
fn human_age_buckets() {
assert_eq!(human_age(Duration::from_secs(30)), "30s");
assert_eq!(human_age(Duration::from_secs(90)), "1m");
assert_eq!(human_age(Duration::from_secs(3600)), "1h");
assert_eq!(human_age(Duration::from_secs(86_400)), "1d");
assert_eq!(human_age(Duration::from_secs(7_776_000)), "3mo");
assert_eq!(human_age(Duration::from_secs(2 * 365 * 86_400)), "2y");
}
#[test]
fn human_date_formats_unix_epoch() {
let t = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let s = human_date(t);
assert!(s.starts_with("2023-") || s.starts_with("2024-"), "got: {s}");
assert_eq!(s.len(), 10, "expected YYYY-MM-DD, got: {s}");
}
#[test]
fn human_int_thousands_dotted() {
assert_eq!(human_int(0), "0");
assert_eq!(human_int(999), "999");
assert_eq!(human_int(1_000), "1.000");
assert_eq!(human_int(47_218), "47.218");
assert_eq!(human_int(1_000_000), "1.000.000");
}
#[test]
fn human_count_buckets() {
assert_eq!(human_count(0.0), "0");
assert_eq!(human_count(42.4), "42");
assert_eq!(human_count(999.0), "999");
assert_eq!(human_count(1_000.0), "1.0 k");
assert_eq!(human_count(1_100.0), "1.1 k");
assert_eq!(human_count(2_500_000.0), "2.5 M");
assert_eq!(human_count(1.5e9), "1.5 G");
}
}