use indicatif::{ProgressBar, ProgressStyle};
use std::io::IsTerminal;
use std::sync::atomic::{AtomicU64, Ordering};
pub struct Progress {
id: u64,
total: u64,
done: AtomicU64,
mode: Mode,
noun: &'static str,
}
enum Mode {
Bar(ProgressBar),
Plain,
Silent,
}
const LOG_INTERVAL: u64 = 25;
static ACTIVE_BAR: std::sync::Mutex<Option<(u64, ProgressBar)>> = std::sync::Mutex::new(None);
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
pub fn with_bars_suspended<R>(f: impl FnOnce() -> R) -> R {
let bar = ACTIVE_BAR
.lock()
.ok()
.and_then(|b| b.as_ref().map(|(_, bar)| bar.clone()));
match bar {
Some(bar) => bar.suspend(f),
None => f(),
}
}
impl Progress {
pub fn new(total: u64, silent: bool) -> Self {
let mode = if silent {
Mode::Silent
} else if std::io::stderr().is_terminal() {
let bar = ProgressBar::new(total);
bar.set_style(
ProgressStyle::with_template("{bar:40} {percent}%")
.unwrap()
.progress_chars("=> "),
);
Mode::Bar(bar)
} else {
Mode::Plain
};
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
if let Mode::Bar(bar) = &mode {
if let Ok(mut active) = ACTIVE_BAR.lock() {
*active = Some((id, bar.clone()));
}
}
Progress {
id,
total,
done: AtomicU64::new(0),
mode,
noun: "images",
}
}
pub fn new_counting(total: u64, silent: bool, noun: &'static str) -> Self {
let mut progress = Progress::new(total, silent);
progress.noun = noun;
progress
}
pub fn tick(&self) {
self.tick_by(1);
}
pub fn tick_by(&self, n: u64) {
let before = self.done.fetch_add(n, Ordering::Relaxed);
let after = before + n;
match &self.mode {
Mode::Bar(bar) => bar.set_position(after),
Mode::Plain => {
if after / LOG_INTERVAL != before / LOG_INTERVAL || after == self.total {
tracing::info!("{}/{} {} processed", after, self.total, self.noun);
}
}
Mode::Silent => {}
}
}
pub fn skip(&self, subject: &str, err: anyhow::Error) {
crate::error_log::report(
tracing::Level::WARN,
&err.context(format!("skipping {subject}")),
Some(subject),
);
}
pub fn finish(self) {
if let Mode::Bar(bar) = &self.mode {
bar.finish_and_clear();
}
}
}
impl Drop for Progress {
fn drop(&mut self) {
if let Ok(mut active) = ACTIVE_BAR.lock() {
if active.as_ref().is_some_and(|(id, _)| *id == self.id) {
*active = None;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn silent_mode_tick_does_not_panic() {
let p = Progress::new(10, true);
for _ in 0..10 {
p.tick();
}
p.finish();
}
#[test]
fn a_skip_is_a_warning_with_the_subject_as_its_path_even_when_silent() {
#[derive(Clone, Default)]
struct Buf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for Buf {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let buf = Buf::default();
let w = buf.clone();
let sub = tracing_subscriber::fmt()
.json()
.with_writer(move || w.clone())
.finish();
tracing::subscriber::with_default(sub, || {
let p = Progress::new(5, true);
p.skip(
"/Fotoğraflar/Çağla.heic",
anyhow::anyhow!("decode failed")
.context(crate::error_kind::ErrorKind::DecodeFailed),
);
});
let line: serde_json::Value = serde_json::from_slice(&buf.0.lock().unwrap()).unwrap();
assert_eq!(line["level"], "WARN");
assert_eq!(line["fields"]["path"], "/Fotoğraflar/Çağla.heic");
assert_eq!(
line["fields"]["message"],
"skipping /Fotoğraflar/Çağla.heic: the file could not be decoded: decode failed"
);
assert_eq!(line["fields"]["kind"], "decode_failed");
}
#[test]
fn zero_total_does_not_panic() {
let p = Progress::new(0, true);
p.tick();
p.finish();
}
#[test]
fn silent_mode_tick_by_does_not_panic() {
let p = Progress::new(100, true);
p.tick_by(40);
p.tick_by(60);
p.finish();
}
#[test]
fn a_caller_can_count_something_other_than_images() {
let p = Progress::new_counting(10, true, "coordinates");
assert_eq!(p.noun, "coordinates");
assert_eq!(Progress::new(10, true).noun, "images");
}
#[test]
fn concurrent_tick_from_multiple_threads_reaches_correct_total() {
use std::sync::Arc;
let progress = Arc::new(Progress::new(1000, true));
let handles: Vec<_> = (0..10)
.map(|_| {
let p = Arc::clone(&progress);
std::thread::spawn(move || {
for _ in 0..100 {
p.tick();
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
assert_eq!(progress.done.load(Ordering::Relaxed), 1000);
}
}
pub fn human_duration(d: std::time::Duration) -> String {
let ms = d.as_millis();
if ms < 1000 {
return format!("{ms}ms");
}
let secs = d.as_secs();
match secs {
0..=9 => {
let s = d.as_millis() as f64 / 1000.0;
format!("{s:.1}s")
}
10..=59 => format!("{secs}s"),
60..=3599 => {
let (m, s) = (secs / 60, secs % 60);
if s == 0 {
format!("{m}m")
} else {
format!("{m}m {s}s")
}
}
_ => {
let (h, m) = (secs / 3600, (secs % 3600) / 60);
if m == 0 {
format!("{h}h")
} else {
format!("{h}h {m}m")
}
}
}
}
pub fn human_duration_ms(ms: u64) -> String {
human_duration(std::time::Duration::from_millis(ms))
}
#[cfg(test)]
mod duration_tests {
use super::{human_duration, human_duration_ms};
use std::time::Duration;
#[test]
fn reads_the_way_a_person_would_say_it() {
let cases = [
(Duration::from_millis(0), "0ms"),
(Duration::from_millis(840), "840ms"),
(Duration::from_millis(1000), "1.0s"),
(Duration::from_millis(3240), "3.2s"),
(Duration::from_secs(41), "41s"),
(Duration::from_secs(59), "59s"),
(Duration::from_secs(60), "1m"),
(Duration::from_secs(95), "1m 35s"),
(Duration::from_secs(3599), "59m 59s"),
(Duration::from_secs(3600), "1h"),
(Duration::from_secs(8040), "2h 14m"),
];
for (d, want) in cases {
assert_eq!(human_duration(d), want, "for {d:?}");
}
}
#[test]
fn the_millisecond_form_matches() {
assert_eq!(human_duration_ms(0), "0ms");
assert_eq!(human_duration_ms(5_412_000), "1h 30m");
}
#[test]
fn no_unit_is_ever_shown_as_zero() {
for secs in [3600, 7200, 60, 120, 600] {
let s = human_duration(Duration::from_secs(secs));
assert!(!s.contains(" 0m") && !s.contains(" 0s"), "{secs}s gave {s}");
}
}
}