use std::time::Duration;
const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
pub fn bytes(n: u64) -> String {
let mut value = n as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{n} B")
} else if value >= 100.0 {
format!("{value:.0} {}", UNITS[unit])
} else {
format!("{value:.2} {}", UNITS[unit])
}
}
pub fn rate(bytes_per_sec: f64) -> String {
if !bytes_per_sec.is_finite() || bytes_per_sec <= 0.0 {
return "--".to_string();
}
let mut value = bytes_per_sec;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
format!("{value:.1} {}/s", UNITS[unit])
}
pub fn duration(d: Duration) -> String {
let secs = d.as_secs();
match secs {
0 if d.subsec_millis() == 0 => "0s".to_string(),
0 => format!("{}ms", d.subsec_millis()),
1..=59 => format!("{secs}s"),
60..=3599 => format!("{}m {:02}s", secs / 60, secs % 60),
_ => format!("{}h {:02}m", secs / 3600, (secs % 3600) / 60),
}
}
pub fn eta(remaining: u64, bytes_per_sec: f64) -> String {
if !bytes_per_sec.is_finite() || bytes_per_sec < 1.0 {
return "--".to_string();
}
duration(Duration::from_secs_f64(remaining as f64 / bytes_per_sec))
}
pub fn percent(done: u64, total: Option<u64>) -> String {
match total {
Some(t) if t > 0 => format!("{:.1}%", (done as f64 / t as f64) * 100.0),
_ => "--".to_string(),
}
}
const PARTIALS: [char; 8] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
pub fn bar_parts(done: u64, total: Option<u64>, width: usize) -> (String, String) {
let frac = match total {
Some(t) if t > 0 => (done as f64 / t as f64).clamp(0.0, 1.0),
_ => 0.0,
};
let exact = frac * width as f64;
let whole = (exact.floor() as usize).min(width);
let mut filled = "█".repeat(whole);
let mut used = whole;
let remainder = exact - whole as f64;
if used < width && remainder > 0.02 {
let idx = ((remainder * 8.0).round() as usize).clamp(1, 8) - 1;
filled.push(PARTIALS[idx]);
used += 1;
}
(filled, "░".repeat(width - used))
}
pub fn bar(done: u64, total: Option<u64>, width: usize) -> String {
let (filled, empty) = bar_parts(done, total, width);
format!("{filled}{empty}")
}
pub fn short_url(raw: &str) -> String {
let no_query = raw.split(['?', '#']).next().unwrap_or(raw);
if no_query.len() <= 72 {
return no_query.to_string();
}
let tail = &no_query[no_query.len() - 40..];
format!("{}…{}", &no_query[..30], tail)
}
#[derive(Clone, Copy, Debug)]
pub struct Style {
enabled: bool,
}
impl Style {
pub fn new(enabled: bool) -> Self {
Self {
enabled: enabled && std::env::var_os("NO_COLOR").is_none(),
}
}
pub fn stdout() -> Self {
Self::new(std::io::IsTerminal::is_terminal(&std::io::stdout()))
}
pub fn stderr() -> Self {
Self::new(std::io::IsTerminal::is_terminal(&std::io::stderr()))
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
fn wrap(&self, code: &str, text: &str) -> String {
if self.enabled {
format!("\x1b[{code}m{text}\x1b[0m")
} else {
text.to_string()
}
}
pub fn bold(&self, t: &str) -> String {
self.wrap("1", t)
}
pub fn dim(&self, t: &str) -> String {
self.wrap("2", t)
}
pub fn green(&self, t: &str) -> String {
self.wrap("32", t)
}
pub fn red(&self, t: &str) -> String {
self.wrap("31", t)
}
pub fn yellow(&self, t: &str) -> String {
self.wrap("33", t)
}
pub fn cyan(&self, t: &str) -> String {
self.wrap("36", t)
}
pub fn blue(&self, t: &str) -> String {
self.wrap("34", t)
}
pub fn magenta(&self, t: &str) -> String {
self.wrap("35", t)
}
pub fn bright_green(&self, t: &str) -> String {
self.wrap("92", t)
}
pub fn bright_cyan(&self, t: &str) -> String {
self.wrap("96", t)
}
pub fn bold_green(&self, t: &str) -> String {
self.wrap("1;32", t)
}
pub fn bold_red(&self, t: &str) -> String {
self.wrap("1;31", t)
}
pub fn bold_cyan(&self, t: &str) -> String {
self.wrap("1;36", t)
}
pub fn sep(&self) -> String {
self.dim(" · ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_bytes() {
assert_eq!(bytes(0), "0 B");
assert_eq!(bytes(512), "512 B");
assert_eq!(bytes(1536), "1.50 KiB");
assert_eq!(bytes(13314398618), "12.40 GiB");
assert_eq!(bytes(200 * 1024 * 1024), "200 MiB");
}
#[test]
fn formats_duration() {
assert_eq!(duration(Duration::ZERO), "0s");
assert_eq!(duration(Duration::from_millis(812)), "812ms");
assert_eq!(duration(Duration::from_secs(45)), "45s");
assert_eq!(duration(Duration::from_secs(151)), "2m 31s");
assert_eq!(duration(Duration::from_secs(3900)), "1h 05m");
}
#[test]
fn eta_needs_a_basis() {
assert_eq!(eta(1000, 0.0), "--");
assert_eq!(eta(1000, f64::NAN), "--");
assert_eq!(eta(1024, 1024.0), "1s");
}
#[test]
fn bar_is_width_stable() {
assert_eq!(bar(0, Some(10), 4).chars().count(), 4);
assert_eq!(bar(10, Some(10), 4).chars().count(), 4);
assert_eq!(bar(5, None, 4).chars().count(), 4);
}
#[test]
fn short_url_drops_query() {
assert_eq!(
short_url("https://x.example/file.iso?token=secret"),
"https://x.example/file.iso"
);
}
}