use std::time::Duration;
use tokio::sync::mpsc;
#[derive(Debug, Clone)]
pub(crate) enum SyncProgressEvent {
ListingPage { pages: usize, ids_discovered: usize },
ListingDone,
FetchQueued,
FetchCompleted { failed: bool },
}
pub(crate) struct SyncProgressBars {
listing: indicatif::ProgressBar,
fetch: indicatif::ProgressBar,
_multi: indicatif::MultiProgress,
}
impl SyncProgressBars {
pub(crate) fn new() -> Self {
Self::build(indicatif::MultiProgress::new(), None)
}
pub(crate) fn new_in(multi: &indicatif::MultiProgress, label: &str) -> Self {
Self::build(multi.clone(), Some(label))
}
fn build(multi: indicatif::MultiProgress, label: Option<&str>) -> Self {
let prefixed = label.is_some();
let listing = multi.add(indicatif::ProgressBar::new_spinner());
listing.set_style(listing_style(prefixed));
if let Some(label) = label {
listing.set_prefix(label.to_string());
}
listing.enable_steady_tick(Duration::from_millis(100));
listing.set_message("0 pages, 0 ids found");
let fetch = multi.add(indicatif::ProgressBar::new(0));
fetch.set_style(fetch_style(prefixed));
if let Some(label) = label {
fetch.set_prefix(label.to_string());
}
Self {
listing,
fetch,
_multi: multi,
}
}
pub(crate) async fn drain(self, mut rx: mpsc::UnboundedReceiver<SyncProgressEvent>) {
let mut errors = 0usize;
while let Some(event) = rx.recv().await {
match event {
SyncProgressEvent::ListingPage {
pages,
ids_discovered,
} => {
self.listing
.set_message(format!("{pages} pages, {ids_discovered} ids found"));
}
SyncProgressEvent::ListingDone => self.listing.finish_and_clear(),
SyncProgressEvent::FetchQueued => self.fetch.inc_length(1),
SyncProgressEvent::FetchCompleted { failed } => {
if failed {
errors += 1;
self.fetch.set_message(format!("({errors} errors)"));
}
self.fetch.inc(1);
}
}
}
self.listing.finish_and_clear();
self.fetch.finish();
}
}
#[allow(clippy::expect_used, clippy::literal_string_with_formatting_args)] fn listing_style(prefixed: bool) -> indicatif::ProgressStyle {
let template = if prefixed {
"{prefix:.bold} {spinner:.cyan} Listing mailbox… {msg}"
} else {
"{spinner:.cyan} Listing mailbox… {msg}"
};
indicatif::ProgressStyle::with_template(template).expect("valid indicatif template literal")
}
#[allow(clippy::expect_used, clippy::literal_string_with_formatting_args)] fn fetch_style(prefixed: bool) -> indicatif::ProgressStyle {
let template = if prefixed {
"{prefix:.bold} {bar:40.cyan/blue} {pos}/{len} messages fetched {msg}"
} else {
"{bar:40.cyan/blue} {pos}/{len} messages fetched {msg}"
};
indicatif::ProgressStyle::with_template(template)
.expect("valid indicatif template literal")
.progress_chars("##-")
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[tokio::test]
async fn listing_page_events_update_the_spinner_message() {
let bars = SyncProgressBars::new();
let listing = bars.listing.clone();
let (tx, rx) = mpsc::unbounded_channel();
tx.send(SyncProgressEvent::ListingPage {
pages: 3,
ids_discovered: 150,
})
.unwrap();
drop(tx);
bars.drain(rx).await;
assert_eq!(listing.message(), "3 pages, 150 ids found");
}
#[tokio::test]
async fn listing_done_finishes_and_clears_the_spinner() {
let bars = SyncProgressBars::new();
let listing = bars.listing.clone();
let (tx, rx) = mpsc::unbounded_channel();
tx.send(SyncProgressEvent::ListingDone).unwrap();
drop(tx);
bars.drain(rx).await;
assert!(listing.is_finished());
}
#[tokio::test]
async fn fetch_queued_grows_length_and_fetch_completed_advances_position() {
let bars = SyncProgressBars::new();
let fetch = bars.fetch.clone();
let (tx, rx) = mpsc::unbounded_channel();
tx.send(SyncProgressEvent::FetchQueued).unwrap();
tx.send(SyncProgressEvent::FetchQueued).unwrap();
tx.send(SyncProgressEvent::FetchCompleted { failed: false })
.unwrap();
tx.send(SyncProgressEvent::FetchCompleted { failed: false })
.unwrap();
drop(tx);
bars.drain(rx).await;
assert_eq!(fetch.length(), Some(2));
assert_eq!(fetch.position(), 2);
assert_eq!(fetch.message(), "");
}
#[tokio::test]
async fn failed_fetches_set_a_running_error_count_message() {
let bars = SyncProgressBars::new();
let fetch = bars.fetch.clone();
let (tx, rx) = mpsc::unbounded_channel();
for _ in 0..2 {
tx.send(SyncProgressEvent::FetchQueued).unwrap();
tx.send(SyncProgressEvent::FetchCompleted { failed: true })
.unwrap();
}
drop(tx);
bars.drain(rx).await;
assert_eq!(fetch.position(), 2);
assert_eq!(fetch.message(), "(2 errors)");
}
#[tokio::test]
async fn both_bars_finish_even_when_the_channel_closes_before_listing_done() {
let bars = SyncProgressBars::new();
let listing = bars.listing.clone();
let fetch = bars.fetch.clone();
let (tx, rx) = mpsc::unbounded_channel();
tx.send(SyncProgressEvent::ListingPage {
pages: 1,
ids_discovered: 5,
})
.unwrap();
drop(tx);
bars.drain(rx).await;
assert!(listing.is_finished());
assert!(fetch.is_finished());
}
#[test]
fn new_in_prefixes_both_bars_with_the_account_label() {
let multi = indicatif::MultiProgress::new();
let bars = SyncProgressBars::new_in(&multi, "jky.greens");
assert_eq!(bars.listing.prefix(), "jky.greens");
assert_eq!(bars.fetch.prefix(), "jky.greens");
}
#[tokio::test]
async fn new_in_two_accounts_drain_independently_on_one_shared_multi_progress() {
let multi = indicatif::MultiProgress::new();
let account_a = SyncProgressBars::new_in(&multi, "acct-a");
let account_b = SyncProgressBars::new_in(&multi, "acct-b");
let fetch_a = account_a.fetch.clone();
let fetch_b = account_b.fetch.clone();
let (tx_a, rx_a) = mpsc::unbounded_channel();
let (tx_b, rx_b) = mpsc::unbounded_channel();
tx_a.send(SyncProgressEvent::FetchQueued).unwrap();
tx_a.send(SyncProgressEvent::FetchCompleted { failed: false })
.unwrap();
drop(tx_a);
tx_b.send(SyncProgressEvent::FetchQueued).unwrap();
tx_b.send(SyncProgressEvent::FetchQueued).unwrap();
tx_b.send(SyncProgressEvent::FetchCompleted { failed: true })
.unwrap();
tx_b.send(SyncProgressEvent::FetchCompleted { failed: false })
.unwrap();
drop(tx_b);
account_a.drain(rx_a).await;
account_b.drain(rx_b).await;
assert_eq!(fetch_a.position(), 1);
assert_eq!(fetch_b.position(), 2);
assert_eq!(fetch_b.length(), Some(2));
assert_eq!(fetch_b.message(), "(1 errors)");
}
}