use std::io::{IsTerminal, stderr};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const REDRAW_INTERVAL: Duration = Duration::from_millis(100);
const BAR_WIDTH: usize = 40;
const LINE_WIDTH: usize = 128;
struct Inner {
total: u64,
done: u64,
files_total: usize,
files_done: usize,
errors: usize,
last_render: Option<Instant>,
finished: bool,
}
#[derive(Clone)]
pub struct Progress {
label: &'static str,
tty: bool,
inner: Arc<Mutex<Inner>>,
}
impl Progress {
pub fn start(label: &'static str, total_bytes: u64, files_total: usize) -> Self {
Self {
label,
tty: stderr().is_terminal(),
inner: Arc::new(Mutex::new(Inner {
total: total_bytes,
done: 0,
files_total,
files_done: 0,
errors: 0,
last_render: None,
finished: false,
})),
}
}
pub fn add(&self, bytes: u64) {
let throttled = {
let mut inner = self.inner.lock().expect("progress mutex poisoned");
inner.done += bytes;
inner
.last_render
.is_some_and(|t| t.elapsed() < REDRAW_INTERVAL)
};
if !throttled {
self.draw();
}
}
pub fn file_done(&self) {
self.inner
.lock()
.expect("progress mutex poisoned")
.files_done += 1;
self.draw();
}
pub fn note_error(&self) {
self.inner.lock().expect("progress mutex poisoned").errors += 1;
self.draw();
}
pub fn finish(&self) {
if !self.tty {
self.inner.lock().expect("progress mutex poisoned").finished = true;
return;
}
let mut inner = self.inner.lock().expect("progress mutex poisoned");
if inner.finished {
return;
}
self.draw_frame(&mut inner);
inner.finished = true;
drop(inner);
eprintln!();
}
fn draw(&self) {
if !self.tty {
return;
}
let mut inner = self.inner.lock().expect("progress mutex poisoned");
if inner.finished {
return;
}
self.draw_frame(&mut inner);
}
fn draw_frame(&self, inner: &mut Inner) {
inner.last_render = Some(Instant::now());
let pct = if inner.total > 0 {
(inner.done.min(inner.total)) as f64 / inner.total as f64 * 100.0
} else {
100.0
};
let filled = ((pct / 100.0) * BAR_WIDTH as f64).round() as usize;
let filled = filled.min(BAR_WIDTH);
let (head, rest) = match filled {
0 => (String::new(), ".".repeat(BAR_WIDTH)),
BAR_WIDTH => ("=".repeat(BAR_WIDTH), String::new()),
_ => (
format!("{}>", "=".repeat(filled - 1)),
".".repeat(BAR_WIDTH - filled),
),
};
let files = format!(" {:>4}/{} files", inner.files_done, inner.files_total);
let err = if inner.errors > 0 {
format!(", {} err", inner.errors)
} else {
String::new()
};
let msg = format!(
"{} [{head}{rest}] {:>5.1}% {:>9}/{:<9}{files}{err}",
self.label,
pct,
fmt_bytes(inner.done),
fmt_bytes(inner.total),
);
let msg = truncate(&msg, LINE_WIDTH);
eprint!("\r{msg:<LINE_WIDTH$}");
}
}
pub fn stderr_note(msg: &str) {
if stderr().is_terminal() {
eprintln!("{msg}");
}
}
pub fn fmt_bytes(n: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut v = n as f64;
for (i, unit) in UNITS.iter().enumerate() {
if v < 1024.0 || i == UNITS.len() - 1 {
return if i == 0 {
format!("{n}B")
} else {
format!("{v:.1}{unit}")
};
}
v /= 1024.0;
}
unreachable!(
"The last item in `units` will always return a result; this branch is unreachable."
)
}
fn truncate(s: &str, max_chars: usize) -> String {
s.chars().take(max_chars).collect()
}
#[cfg(test)]
mod tests {
use super::{Progress, fmt_bytes};
#[test]
fn test_fmt_bytes() {
assert_eq!(fmt_bytes(0), "0B");
assert_eq!(fmt_bytes(512), "512B");
assert_eq!(fmt_bytes(1536), "1.5KB");
assert_eq!(fmt_bytes(18 * 1024 * 1024 * 1024), "18.0GB");
}
#[test]
fn test_progress_non_tty_silent() {
let progress = Progress::start("test", 1000, 4);
progress.add(400);
progress.file_done();
progress.note_error();
progress.add(600);
progress.file_done();
progress.finish();
progress.finish();
}
#[test]
fn test_progress_clone_shares_state() {
let progress = Progress::start("test", 100, 2);
let cloned = progress.clone();
cloned.file_done();
progress.file_done();
cloned.note_error();
progress.finish();
cloned.finish(); }
}