use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use crate::output::{CYAN, RESET, YELLOW, should_use_colors};
pub trait Progress {
fn task_started(&mut self, name: &str);
fn task_done(&mut self);
fn bytes_transferred(&mut self, n: u64);
fn set_total(&mut self, n: Option<u64>);
#[allow(dead_code)]
fn info(&mut self, msg: &str);
#[allow(dead_code)]
fn warn(&mut self, msg: &str);
#[allow(dead_code)]
fn cancel(&mut self);
}
pub struct TerminalProgress {
pb: ProgressBar,
has_total: bool,
}
impl TerminalProgress {
pub fn new(initial_message: &str) -> Self {
let pb = ProgressBar::with_draw_target(None, ProgressDrawTarget::stderr());
apply_spinner_style(&pb);
pb.set_message(initial_message.to_string());
pb.enable_steady_tick(std::time::Duration::from_millis(100));
crate::signal::register_progress_bar(&pb);
Self {
pb,
has_total: false,
}
}
#[cfg(test)]
pub fn hidden(initial_message: &str) -> Self {
let pb = ProgressBar::hidden();
pb.set_message(initial_message.to_string());
Self {
pb,
has_total: false,
}
}
}
impl Progress for TerminalProgress {
fn task_started(&mut self, name: &str) {
self.pb.set_message(name.to_string());
}
fn task_done(&mut self) {
self.pb.finish_and_clear();
}
fn bytes_transferred(&mut self, n: u64) {
self.pb.set_position(n);
}
fn set_total(&mut self, n: Option<u64>) {
match n {
Some(total) => {
if !self.has_total {
apply_bytes_style(&self.pb);
self.has_total = true;
}
self.pb.set_length(total);
}
None => {
if self.has_total {
apply_spinner_style(&self.pb);
self.has_total = false;
}
}
}
}
fn info(&mut self, msg: &str) {
self.pb.println(format_info_line(msg, should_use_colors()));
}
fn warn(&mut self, msg: &str) {
self.pb.println(format_warn_line(msg, should_use_colors()));
}
fn cancel(&mut self) {
self.pb.finish_and_clear();
}
}
fn format_info_line(msg: &str, use_colors: bool) -> String {
if use_colors {
format!("{CYAN}\u{2192}{RESET} {msg}")
} else {
format!("\u{2192} {msg}")
}
}
fn format_warn_line(msg: &str, use_colors: bool) -> String {
if use_colors {
format!("{YELLOW}\u{26A0}{RESET} {msg}")
} else {
format!("\u{26A0} {msg}")
}
}
fn apply_spinner_style(pb: &ProgressBar) {
if should_use_colors() {
pb.set_style(
ProgressStyle::default_spinner()
.tick_chars("\u{280B}\u{2819}\u{2839}\u{2838}\u{283C}\u{2834}\u{2826}\u{2827}\u{2807}\u{280F}")
.template("{spinner} {msg}")
.unwrap(),
);
} else {
pb.set_style(
ProgressStyle::default_spinner()
.tick_chars("/-\\|")
.template("{spinner} {msg}")
.unwrap(),
);
}
}
fn apply_bytes_style(pb: &ProgressBar) {
if should_use_colors() {
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner} {msg} [{bar:40}] {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars(
"\u{2588}\u{2589}\u{258A}\u{258B}\u{258C}\u{258D}\u{258E}\u{258F} ",
),
);
} else {
pb.set_style(
ProgressStyle::default_bar()
.template("{msg} [{bar:40}] {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("#>-"),
);
}
}
#[cfg(test)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProgressEvent {
TaskStarted(String),
TaskDone,
BytesTransferred(u64),
SetTotal(Option<u64>),
Info(String),
Warn(String),
Cancel,
}
#[cfg(test)]
pub struct MockProgress {
events: Vec<ProgressEvent>,
}
#[cfg(test)]
impl MockProgress {
pub fn new() -> Self {
Self { events: Vec::new() }
}
pub fn events(&self) -> &[ProgressEvent] {
&self.events
}
}
#[cfg(test)]
impl Default for MockProgress {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
impl Progress for MockProgress {
fn task_started(&mut self, name: &str) {
self.events
.push(ProgressEvent::TaskStarted(name.to_string()));
}
fn task_done(&mut self) {
self.events.push(ProgressEvent::TaskDone);
}
fn bytes_transferred(&mut self, n: u64) {
self.events.push(ProgressEvent::BytesTransferred(n));
}
fn set_total(&mut self, n: Option<u64>) {
self.events.push(ProgressEvent::SetTotal(n));
}
fn info(&mut self, msg: &str) {
self.events.push(ProgressEvent::Info(msg.to_string()));
}
fn warn(&mut self, msg: &str) {
self.events.push(ProgressEvent::Warn(msg.to_string()));
}
fn cancel(&mut self) {
self.events.push(ProgressEvent::Cancel);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_records_task_lifecycle() {
let mut p = MockProgress::new();
p.task_started("rsync /opt/foo");
p.bytes_transferred(1024);
p.task_done();
assert_eq!(
p.events(),
&[
ProgressEvent::TaskStarted("rsync /opt/foo".to_string()),
ProgressEvent::BytesTransferred(1024),
ProgressEvent::TaskDone,
]
);
}
#[test]
fn mock_records_bytes_progression() {
let mut p = MockProgress::new();
p.set_total(Some(1_000_000));
p.bytes_transferred(250_000);
p.bytes_transferred(500_000);
p.bytes_transferred(1_000_000);
p.task_done();
assert_eq!(
p.events(),
&[
ProgressEvent::SetTotal(Some(1_000_000)),
ProgressEvent::BytesTransferred(250_000),
ProgressEvent::BytesTransferred(500_000),
ProgressEvent::BytesTransferred(1_000_000),
ProgressEvent::TaskDone,
]
);
}
#[test]
fn mock_records_info_and_warn() {
let mut p = MockProgress::new();
p.info("starting backup");
p.warn("config missing optional key");
assert_eq!(
p.events(),
&[
ProgressEvent::Info("starting backup".to_string()),
ProgressEvent::Warn("config missing optional key".to_string()),
]
);
}
#[test]
fn mock_records_cancel() {
let mut p = MockProgress::new();
p.task_started("rsync");
p.cancel();
assert_eq!(
p.events(),
&[
ProgressEvent::TaskStarted("rsync".to_string()),
ProgressEvent::Cancel,
]
);
}
#[test]
fn terminal_progress_lifecycle_does_not_panic() {
let mut p = TerminalProgress::hidden("test");
p.task_started("step 1");
p.set_total(Some(100));
p.bytes_transferred(50);
p.set_total(None);
p.info("informational");
p.warn("warning");
p.task_done();
}
#[test]
fn terminal_progress_set_total_swaps_styles_idempotently() {
let mut p = TerminalProgress::hidden("test");
p.set_total(Some(1024));
p.set_total(Some(2048));
p.set_total(None);
p.set_total(None);
p.task_done();
}
#[test]
fn format_info_line_omits_color_when_disabled() {
let line = format_info_line("hello", false);
assert_eq!(line, "\u{2192} hello");
assert!(!line.contains('\x1b'));
}
#[test]
fn format_info_line_wraps_glyph_in_cyan_when_enabled() {
let line = format_info_line("hello", true);
assert_eq!(line, format!("{CYAN}\u{2192}{RESET} hello"));
}
#[test]
fn format_warn_line_omits_color_when_disabled() {
let line = format_warn_line("careful", false);
assert_eq!(line, "\u{26A0} careful");
assert!(!line.contains('\x1b'));
}
#[test]
fn format_warn_line_wraps_glyph_in_yellow_when_enabled() {
let line = format_warn_line("careful", true);
assert_eq!(line, format!("{YELLOW}\u{26A0}{RESET} careful"));
}
}