use std::io::{self, Write};
use std::sync::{Arc, Mutex};
struct BarConfig {
width: usize,
fill: char,
empty: char,
}
impl Default for BarConfig {
fn default() -> Self {
Self {
width: 40,
fill: '█',
empty: '░',
}
}
}
struct ProgressBarState {
current: u64,
total: u64,
message: String,
finished: bool,
writer: Box<dyn Write + Send>,
config: BarConfig,
is_tty: bool,
}
impl ProgressBarState {
fn render(&mut self) {
let ratio = self.current as f64 / self.total.max(1) as f64;
let filled = (ratio * self.config.width as f64).round() as usize;
let empty = self.config.width - filled;
let percent = (ratio * 100.0) as u64;
let bar: String = std::iter::repeat_n(self.config.fill, filled)
.chain(std::iter::repeat_n(self.config.empty, empty))
.collect();
let line = if self.message.is_empty() {
format!("[{}] {:>3}% {}/{}", bar, percent, self.current, self.total)
} else {
format!(
"[{}] {:>3}% {}/{} {}",
bar, percent, self.current, self.total, self.message
)
};
if self.is_tty {
write!(self.writer, "\r{}", line).ok();
} else {
writeln!(self.writer, "{}", line).ok();
}
self.writer.flush().ok();
}
fn finalize(&mut self, symbol: &str, color_code: &str, msg: &str) {
if self.finished {
return;
}
self.finished = true;
if self.is_tty {
write!(
self.writer,
"\r\x1b[2K{}{}\x1b[0m {}\n",
color_code, symbol, msg
)
.ok();
} else {
writeln!(self.writer, "{} {}", symbol, msg).ok();
}
self.writer.flush().ok();
}
}
#[cfg(unix)]
fn is_stdout_tty() -> bool {
extern "C" {
fn isatty(fd: std::os::raw::c_int) -> std::os::raw::c_int;
}
unsafe { isatty(1) != 0 }
}
#[cfg(windows)]
fn is_stdout_tty() -> bool {
use std::os::windows::io::AsRawHandle;
extern "system" {
fn GetConsoleMode(handle: *mut std::ffi::c_void, mode: *mut u32) -> i32;
}
let handle = io::stdout().as_raw_handle();
let mut mode: u32 = 0;
unsafe { GetConsoleMode(handle as *mut _, &mut mode) != 0 }
}
#[cfg(not(any(unix, windows)))]
fn is_stdout_tty() -> bool {
false
}
pub struct ProgressBarBuilder {
total: u64,
config: BarConfig,
message: String,
writer: Option<Box<dyn Write + Send>>,
tty_override: Option<bool>,
}
impl ProgressBarBuilder {
pub fn width(mut self, width: usize) -> Self {
self.config.width = width;
self
}
pub fn fill(mut self, ch: char) -> Self {
self.config.fill = ch;
self
}
pub fn empty(mut self, ch: char) -> Self {
self.config.empty = ch;
self
}
pub fn message(mut self, msg: &str) -> Self {
self.message = msg.to_string();
self
}
pub fn writer(mut self, writer: Box<dyn Write + Send>) -> Self {
self.writer = Some(writer);
self
}
pub fn tty(mut self, is_tty: bool) -> Self {
self.tty_override = Some(is_tty);
self
}
pub fn start(self) -> ProgressBar {
let total = if self.total == 0 { 1 } else { self.total };
let has_custom_writer = self.writer.is_some();
let writer = self.writer.unwrap_or_else(|| Box::new(io::stdout()));
let is_tty = self.tty_override.unwrap_or_else(|| {
if has_custom_writer {
false
} else {
is_stdout_tty()
}
});
let mut state = ProgressBarState {
current: 0,
total,
message: self.message,
finished: false,
writer,
config: self.config,
is_tty,
};
state.render();
ProgressBar {
state: Arc::new(Mutex::new(state)),
}
}
}
pub struct ProgressBar {
state: Arc<Mutex<ProgressBarState>>,
}
impl ProgressBar {
#[allow(clippy::new_ret_no_self)]
pub fn new(total: u64) -> ProgressBarBuilder {
ProgressBarBuilder {
total,
config: BarConfig::default(),
message: String::new(),
writer: None,
tty_override: None,
}
}
pub fn tick(&self, amount: u64) {
let mut s = self.state.lock().unwrap();
if s.finished {
return;
}
s.current = s.current.saturating_add(amount).min(s.total);
s.render();
}
pub fn set_message(&self, msg: &str) {
let mut s = self.state.lock().unwrap();
s.message = msg.to_string();
}
pub fn success(&self, msg: &str) {
let mut s = self.state.lock().unwrap();
s.finalize("✔", "\x1b[32m", msg);
}
pub fn fail(&self, msg: &str) {
let mut s = self.state.lock().unwrap();
s.finalize("✖", "\x1b[31m", msg);
}
}
impl Clone for ProgressBar {
fn clone(&self) -> Self {
ProgressBar {
state: Arc::clone(&self.state),
}
}
}
impl Drop for ProgressBar {
fn drop(&mut self) {
if Arc::strong_count(&self.state) == 1 {
if let Ok(mut s) = self.state.lock() {
if !s.finished {
let _ = writeln!(s.writer);
let _ = s.writer.flush();
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct TestWriter(Arc<Mutex<Vec<u8>>>);
impl TestWriter {
fn new() -> Self {
TestWriter(Arc::new(Mutex::new(Vec::new())))
}
fn output(&self) -> String {
String::from_utf8_lossy(&self.0.lock().unwrap()).to_string()
}
}
impl io::Write for TestWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn make_writer() -> (TestWriter, Box<dyn Write + Send>) {
let tw = TestWriter::new();
let boxed: Box<dyn Write + Send> = Box::new(tw.clone());
(tw, boxed)
}
#[test]
fn test_builder_defaults() {
let (tw, w) = make_writer();
let _bar = ProgressBar::new(100).writer(w).start();
let out = tw.output();
assert!(
out.contains("0% 0/100"),
"expected default render, got: {out}"
);
}
#[test]
fn test_custom_writer_receives_output() {
let (tw, w) = make_writer();
let bar = ProgressBar::new(10).writer(w).start();
bar.tick(5);
let out = tw.output();
assert!(!out.is_empty(), "custom writer should receive output");
assert!(out.contains("5/10"));
}
#[test]
fn test_start_produces_initial_render() {
let (tw, w) = make_writer();
let _bar = ProgressBar::new(50).writer(w).start();
let out = tw.output();
assert!(out.contains("0/50"), "start should render initial state");
}
#[test]
fn test_tty_mode_uses_cr() {
let (tw, w) = make_writer();
let _bar = ProgressBar::new(10).writer(w).tty(true).start();
let out = tw.output();
assert!(
out.starts_with('\r'),
"TTY mode should start with \\r, got: {out}"
);
}
#[test]
fn test_non_tty_mode_uses_newlines_no_ansi() {
let (tw, w) = make_writer();
let bar = ProgressBar::new(10).writer(w).start(); bar.tick(5);
bar.success("done");
let out = tw.output();
assert!(!out.contains("\x1b["), "non-TTY should have no ANSI codes");
assert!(out.contains('\n'), "non-TTY should use newlines");
}
#[test]
fn test_custom_writer_defaults_non_tty() {
let (tw, w) = make_writer();
let _bar = ProgressBar::new(10).writer(w).start();
let out = tw.output();
assert!(
!out.starts_with('\r'),
"custom writer should default to non-TTY"
);
}
#[test]
fn test_non_tty_finalization_omits_ansi() {
let (tw, w) = make_writer();
let bar = ProgressBar::new(10).writer(w).start();
bar.success("all good");
let out = tw.output();
assert!(
!out.contains("\x1b["),
"non-TTY finalization should omit ANSI"
);
assert!(out.contains("✔"));
assert!(out.contains("all good"));
}
#[test]
fn test_drop_without_finalization_writes_newline() {
let (tw, w) = make_writer();
{
let _bar = ProgressBar::new(10).writer(w).start();
}
let out = tw.output();
assert!(out.ends_with('\n'), "drop should write trailing newline");
}
#[test]
fn test_drop_after_finalization_no_extra_output() {
let (tw, w) = make_writer();
let out_before;
{
let bar = ProgressBar::new(10).writer(w).start();
bar.success("done");
out_before = tw.output();
}
let out_after = tw.output();
assert_eq!(
out_before, out_after,
"drop after finalization should add nothing"
);
}
#[test]
fn test_progress_bar_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ProgressBar>();
}
use quickcheck::quickcheck;
quickcheck! {
fn prop_builder_defaults(total: u64) -> bool {
let total = total.max(1);
let (_tw, w) = make_writer();
let bar = ProgressBar::new(total).writer(w).start();
let s = bar.state.lock().unwrap();
s.config.width == 40
&& s.config.fill == '█'
&& s.config.empty == '░'
&& s.current == 0
&& s.total == total
}
fn prop_render_output(current: u64, total: u64, width: u8, msg_bytes: Vec<u8>) -> bool {
let total = total.max(1);
let current = current.min(total);
let width = (width as usize).max(1).min(200);
let msg: String = String::from_utf8_lossy(&msg_bytes)
.chars()
.filter(|c| !c.is_control())
.take(50)
.collect();
let fill = '█';
let empty_ch = '░';
let ratio = current as f64 / total as f64;
let filled = (ratio * width as f64).round() as usize;
let empty_count = width - filled;
let percent = (ratio * 100.0) as u64;
let expected_bar: String = std::iter::repeat_n(fill, filled)
.chain(std::iter::repeat_n(empty_ch, empty_count))
.collect();
let count_str = format!("{current}/{total}");
let pct_str = format!("{percent}%");
let (tw, w) = make_writer();
let bar = ProgressBar::new(total)
.writer(w)
.width(width)
.message(&msg)
.start();
if current > 0 {
bar.tick(current);
}
let out = tw.output();
out.contains(&expected_bar)
&& out.contains(&count_str)
&& out.contains(&pct_str)
&& (msg.is_empty() || out.contains(&msg))
}
fn prop_tick_accumulation(total: u64, ticks: Vec<u64>) -> bool {
let total = total.max(1);
let (_, w) = make_writer();
let bar = ProgressBar::new(total).writer(w).start();
for t in &ticks {
bar.tick(*t);
}
let s = bar.state.lock().unwrap();
let expected = ticks.iter().fold(0u64, |acc, t| acc.saturating_add(*t)).min(total);
s.current == expected
}
fn prop_finalization_output(msg_bytes: Vec<u8>, use_success: bool) -> bool {
let msg: String = String::from_utf8_lossy(&msg_bytes)
.chars()
.filter(|c| !c.is_control())
.take(50)
.collect();
let (tw, w) = make_writer();
let bar = ProgressBar::new(10).writer(w).start();
if use_success {
bar.success(&msg);
} else {
bar.fail(&msg);
}
let out = tw.output();
if use_success {
out.contains("✔") && out.contains(&msg)
} else {
out.contains("✖") && out.contains(&msg)
}
}
fn prop_finalized_rejects_ticks(total: u64, tick_amount: u64) -> bool {
let total = total.max(1);
let (tw, w) = make_writer();
let bar = ProgressBar::new(total).writer(w).start();
bar.success("done");
let out_before = tw.output();
let counter_before = bar.state.lock().unwrap().current;
bar.tick(tick_amount.max(1));
let out_after = tw.output();
let counter_after = bar.state.lock().unwrap().current;
counter_before == counter_after && out_before == out_after
}
fn prop_no_ansi_in_non_tty(total: u64, ticks: Vec<u64>, msg_bytes: Vec<u8>, finalize: bool) -> bool {
let total = total.max(1);
let msg: String = String::from_utf8_lossy(&msg_bytes)
.chars()
.filter(|c| !c.is_control())
.take(50)
.collect();
let (tw, w) = make_writer();
let bar = ProgressBar::new(total).writer(w).message(&msg).start();
for t in &ticks {
bar.tick(*t);
}
if finalize {
bar.success(&msg);
} else {
bar.fail(&msg);
}
let out = tw.output();
!out.contains("\x1b[")
}
fn prop_concurrent_ticks(total: u64, tick_amounts: Vec<u64>) -> bool {
let total = total.max(1);
let tick_amounts: Vec<u64> = tick_amounts.into_iter().take(20).collect();
let (_, w) = make_writer();
let bar = ProgressBar::new(total).writer(w).start();
std::thread::scope(|s| {
for amount in &tick_amounts {
let bar = bar.clone();
let amount = *amount;
s.spawn(move || bar.tick(amount));
}
});
let s = bar.state.lock().unwrap();
let expected = tick_amounts.iter().fold(0u64, |acc, t| acc.saturating_add(*t)).min(total);
s.current == expected
}
}
}