use std::io::IsTerminal;
use std::time::Duration;
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
const TICK_INTERVAL: Duration = Duration::from_millis(120);
const WORKING_LABEL: &str = "Working";
fn can_draw() -> bool {
std::io::stderr().is_terminal()
}
fn build_bar(
title: &str,
total: u64,
target: ProgressDrawTarget,
steady_tick: bool,
) -> ProgressBar {
let bar = ProgressBar::with_draw_target(Some(total.max(1)), target);
if let Ok(style) =
ProgressStyle::with_template(" {msg}\n [{bar:32}] {percent:>3}% {prefix} ETA {eta}")
{
bar.set_style(style.progress_chars("=> "));
}
bar.set_message(title.to_owned());
if steady_tick {
bar.enable_steady_tick(TICK_INTERVAL);
}
bar
}
pub struct Progress {
bar: Option<ProgressBar>,
}
impl Progress {
pub fn new(title: &str, total: u64) -> Self {
if !can_draw() {
return Self { bar: None };
}
Self {
bar: Some(build_bar(title, total, ProgressDrawTarget::stderr(), true)),
}
}
#[cfg(test)]
fn to_target(title: &str, total: u64, target: ProgressDrawTarget) -> Self {
Self {
bar: Some(build_bar(title, total, target, false)),
}
}
pub fn set_detail(&self, detail: &str) {
if let Some(bar) = &self.bar {
bar.set_prefix(detail.to_owned());
}
}
pub fn advance(&self, units: u64) {
if let Some(bar) = &self.bar {
bar.inc(units);
}
}
#[cfg(test)]
fn redraw(&self) {
if let Some(bar) = &self.bar {
bar.tick();
}
}
pub fn finish(self) {
if let Some(bar) = &self.bar {
bar.finish_and_clear();
}
}
}
pub struct Activity {
bar: Option<ProgressBar>,
}
impl Activity {
pub fn start() -> Self {
if !can_draw() {
return Self { bar: None };
}
let bar = ProgressBar::new_spinner();
if let Ok(style) = ProgressStyle::with_template(" {spinner} {msg}") {
bar.set_style(style);
}
bar.set_message(WORKING_LABEL);
bar.enable_steady_tick(TICK_INTERVAL);
Self { bar: Some(bar) }
}
pub fn finish(self) {
if let Some(bar) = &self.bar {
bar.finish_and_clear();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_is_drawn_without_a_terminal() {
assert!(!can_draw(), "the test harness must not hold a terminal");
let progress = Progress::new("scanning", 100);
assert!(progress.bar.is_none());
progress.set_detail("a file");
progress.advance(10);
progress.finish();
let activity = Activity::start();
assert!(activity.bar.is_none());
activity.finish();
}
#[test]
fn the_bar_draws_what_the_user_came_for() {
let terminal = indicatif::InMemoryTerm::new(6, 90);
let progress = Progress::to_target(
"Analysing 3 of 40 files",
100,
ProgressDrawTarget::term_like_with_hz(Box::new(terminal.clone()), 255),
);
progress.set_detail("holiday.png");
progress.advance(25);
progress.redraw();
let drawn = terminal.contents();
assert!(drawn.contains("Analysing 3 of 40 files"), "got: {drawn}");
assert!(drawn.contains("holiday.png"), "got: {drawn}");
assert!(drawn.contains("25%"), "got: {drawn}");
assert!(drawn.contains("ETA"), "got: {drawn}");
progress.finish();
assert!(
terminal.contents().is_empty(),
"got: {}",
terminal.contents()
);
}
#[test]
fn the_estimate_is_weighted_by_work_and_not_by_file_count() {
let terminal = indicatif::InMemoryTerm::new(6, 90);
let progress = Progress::to_target(
"Analysing 2 of 2 files",
104,
ProgressDrawTarget::term_like_with_hz(Box::new(terminal.clone()), 255),
);
progress.advance(4);
progress.redraw();
let drawn = terminal.contents();
assert!(drawn.contains("4%"), "got: {drawn}");
assert!(!drawn.contains("50%"), "got: {drawn}");
progress.advance(100);
progress.redraw();
assert!(
terminal.contents().contains("100%"),
"got: {}",
terminal.contents()
);
}
#[test]
fn the_activity_label_names_no_stage() {
let forbidden = [
"decrypt",
"password",
"key",
"salt",
"hypothesis",
"payload",
"header",
"extract",
"authenticat",
"verify",
];
let label = WORKING_LABEL.to_lowercase();
for term in forbidden {
assert!(
!label.contains(term),
"the label must reveal no stage, found {term:?} in {WORKING_LABEL:?}"
);
}
}
}