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 },
RateLimited {
status: u16,
delay_secs: u64,
attempt: u32,
},
}
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,
}
}
pub(crate) async fn drain(self, rx: mpsc::UnboundedReceiver<SyncProgressEvent>) {
self.apply_events(rx).await;
self.listing.finish_and_clear();
self.fetch.finish();
}
pub(crate) fn drain_and_remove(
self,
rx: mpsc::UnboundedReceiver<SyncProgressEvent>,
) -> impl std::future::Future<Output = ()> {
let bars = DetachOnDrop(self);
async move { bars.0.apply_events(rx).await }
}
async fn apply_events(&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(if errors > 0 {
format!("({errors} errors)")
} else {
String::new()
});
self.fetch.inc(1);
}
SyncProgressEvent::RateLimited {
status,
delay_secs,
attempt,
} => {
self.fetch.set_message(format!(
"rate limited ({status}), retrying in {delay_secs}s (attempt {attempt})"
));
}
}
}
}
}
struct DetachOnDrop(SyncProgressBars);
impl Drop for DetachOnDrop {
fn drop(&mut self) {
for bar in [&self.0.listing, &self.0.fetch] {
bar.finish_and_clear();
self.0.multi.remove(bar);
}
}
}
#[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());
}
#[tokio::test]
async fn rate_limited_event_sets_the_fetch_bar_message() {
let bars = SyncProgressBars::new();
let fetch = bars.fetch.clone();
let (tx, rx) = mpsc::unbounded_channel();
tx.send(SyncProgressEvent::RateLimited {
status: 429,
delay_secs: 2,
attempt: 1,
})
.unwrap();
drop(tx);
bars.drain(rx).await;
assert_eq!(
fetch.message(),
"rate limited (429), retrying in 2s (attempt 1)"
);
}
#[tokio::test]
async fn fetch_completed_after_rate_limited_clears_the_message() {
let bars = SyncProgressBars::new();
let fetch = bars.fetch.clone();
let (tx, rx) = mpsc::unbounded_channel();
tx.send(SyncProgressEvent::FetchQueued).unwrap();
tx.send(SyncProgressEvent::RateLimited {
status: 403,
delay_secs: 4,
attempt: 1,
})
.unwrap();
tx.send(SyncProgressEvent::FetchCompleted { failed: false })
.unwrap();
drop(tx);
bars.drain(rx).await;
assert_eq!(fetch.message(), "");
}
#[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)");
}
#[tokio::test]
async fn drain_and_remove_finishes_both_bars() {
let multi = visible_multi_progress();
let bars = SyncProgressBars::new_in(&multi, "acct");
let listing = bars.listing.clone();
let fetch = bars.fetch.clone();
assert!(!listing.is_hidden() && !fetch.is_hidden());
let (tx, rx) = mpsc::unbounded_channel();
tx.send(SyncProgressEvent::FetchQueued).unwrap();
tx.send(SyncProgressEvent::FetchCompleted { failed: true })
.unwrap();
drop(tx);
bars.drain_and_remove(rx).await;
assert!(listing.is_finished());
assert!(fetch.is_finished());
assert!(listing.is_hidden());
assert!(fetch.is_hidden());
assert_eq!(fetch.position(), 1);
assert_eq!(fetch.message(), "(1 errors)");
}
#[tokio::test]
async fn drain_and_remove_detaches_both_bars_when_cancelled_mid_drain() {
let multi = visible_multi_progress();
let bars = SyncProgressBars::new_in(&multi, "acct");
let listing = bars.listing.clone();
let fetch = bars.fetch.clone();
assert!(!listing.is_hidden() && !fetch.is_hidden());
let (tx, rx) = mpsc::unbounded_channel::<SyncProgressEvent>();
let render_task = tokio::spawn(bars.drain_and_remove(rx));
render_task.abort();
assert!(render_task.await.unwrap_err().is_cancelled());
assert!(listing.is_hidden());
assert!(fetch.is_hidden());
drop(tx);
}
fn visible_multi_progress() -> indicatif::MultiProgress {
indicatif::MultiProgress::with_draw_target(indicatif::ProgressDrawTarget::term_like(
Box::new(indicatif::InMemoryTerm::new(10, 100)),
))
}
fn screen_lines(term: &indicatif::InMemoryTerm) -> Vec<String> {
term.contents()
.lines()
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect()
}
async fn finish_account(bars: SyncProgressBars, fetched: usize) {
let (tx, rx) = mpsc::unbounded_channel();
tx.send(SyncProgressEvent::ListingDone).unwrap();
for _ in 0..fetched {
tx.send(SyncProgressEvent::FetchQueued).unwrap();
tx.send(SyncProgressEvent::FetchCompleted { failed: false })
.unwrap();
}
drop(tx);
bars.drain_and_remove(rx).await;
}
#[tokio::test]
async fn drain_and_remove_keeps_every_summary_line_while_other_bars_draw() {
let term = indicatif::InMemoryTerm::new(50, 100);
let multi = indicatif::MultiProgress::with_draw_target(
indicatif::ProgressDrawTarget::term_like_with_hz(Box::new(term.clone()), 1),
);
let a = SyncProgressBars::new_in(&multi, "a");
let b = SyncProgressBars::new_in(&multi, "b");
let c = SyncProgressBars::new_in(&multi, "c");
let c_fetch = c.fetch.clone();
c_fetch.inc_length(100);
finish_account(b, 2).await;
multi.suspend(|| indicatif::TermLike::write_line(&term, "b: summary").unwrap());
finish_account(a, 1).await;
for _ in 0..50 {
c_fetch.inc(1);
}
multi.suspend(|| indicatif::TermLike::write_line(&term, "a: summary").unwrap());
finish_account(c, 0).await;
multi.suspend(|| indicatif::TermLike::write_line(&term, "c: summary").unwrap());
indicatif::TermLike::write_line(&term, "combined").unwrap();
assert_eq!(
screen_lines(&term),
["b: summary", "a: summary", "c: summary", "combined"]
);
}
}