use std::io::IsTerminal;
use std::str::FromStr;
use std::time::Duration;
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ProgressMode {
#[default]
Auto,
Always,
Never,
}
impl FromStr for ProgressMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"auto" => Ok(Self::Auto),
"always" => Ok(Self::Always),
"never" => Ok(Self::Never),
other => Err(format!(
"invalid progress mode {other:?}; expected one of `auto`, `always`, `never`"
)),
}
}
}
#[derive(Debug)]
pub struct Progress {
inner: Option<MultiProgress>,
emit_summary: bool,
}
impl Progress {
pub fn new(mode: ProgressMode) -> Self {
let bars_visible = !matches!(mode, ProgressMode::Never) && std::io::stderr().is_terminal();
let inner =
bars_visible.then(|| MultiProgress::with_draw_target(ProgressDrawTarget::stderr()));
let emit_summary = !matches!(mode, ProgressMode::Never);
Self {
inner,
emit_summary,
}
}
#[cfg(test)]
pub fn null() -> Self {
Self {
inner: None,
emit_summary: false,
}
}
pub fn phase(&self, label: &str, total: Option<u64>) -> Phase {
let Some(multi) = &self.inner else {
return Phase {
bar: None,
fallback_label: self.emit_summary.then(|| label.to_string()),
};
};
let bar = if let Some(n) = total {
let pb = multi.add(ProgressBar::new(n));
pb.set_style(determinate_style());
pb
} else {
let pb = multi.add(ProgressBar::new_spinner());
pb.set_style(spinner_style());
pb.enable_steady_tick(Duration::from_millis(120));
pb
};
bar.set_message(label.to_string());
Phase {
bar: Some(bar),
fallback_label: None,
}
}
pub fn status(&self, message: &str) {
if !self.emit_summary {
return;
}
if let Some(multi) = &self.inner {
let _ = multi.println(format!("· {message}"));
} else {
eprintln!("· {message}");
}
}
pub fn summary(&self, message: &str) {
if !self.emit_summary {
return;
}
if let Some(multi) = &self.inner {
let _ = multi.println(message);
} else {
eprintln!("{message}");
}
}
}
#[derive(Debug)]
pub struct Phase {
bar: Option<ProgressBar>,
fallback_label: Option<String>,
}
impl Phase {
#[cfg(test)]
fn null() -> Self {
Self {
bar: None,
fallback_label: None,
}
}
pub fn inc(&self, n: u64) {
if let Some(bar) = &self.bar {
bar.inc(n);
}
}
pub fn set_message(&self, msg: &str) {
if let Some(bar) = &self.bar {
bar.set_message(msg.to_string());
}
}
pub fn finish(self, summary: &str) {
if let Some(bar) = self.bar {
bar.finish_with_message(summary.to_string());
} else if let Some(label) = self.fallback_label {
eprintln!("· {label} — {summary}");
}
}
}
fn determinate_style() -> ProgressStyle {
ProgressStyle::with_template(
"{spinner:.cyan} {msg} [{wide_bar:.cyan/blue}] {pos}/{len} ({eta})",
)
.expect("valid indicatif template")
.progress_chars("=> ")
}
fn spinner_style() -> ProgressStyle {
ProgressStyle::with_template("{spinner:.cyan} {msg} {elapsed:.dim}")
.expect("valid indicatif template")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn null_handle_methods_are_noops() {
let p = Progress::null();
let phase = p.phase("blaming", Some(100));
phase.inc(50);
phase.set_message("src/foo.rs");
phase.finish("done");
p.status("loading config");
p.summary("0 proposals");
}
#[test]
fn never_mode_resolves_to_hidden() {
let p = Progress::new(ProgressMode::Never);
assert!(p.inner.is_none());
}
#[test]
fn from_str_round_trip() {
assert_eq!("auto".parse::<ProgressMode>().unwrap(), ProgressMode::Auto);
assert_eq!(
"always".parse::<ProgressMode>().unwrap(),
ProgressMode::Always
);
assert_eq!(
"never".parse::<ProgressMode>().unwrap(),
ProgressMode::Never
);
assert!("verbose".parse::<ProgressMode>().is_err());
}
#[test]
fn phase_null_methods_no_panic() {
let phase = Phase::null();
phase.inc(0);
phase.inc(1_000_000);
phase.set_message("");
phase.finish("");
}
}