use std::io::Write;
use std::time::Duration;
use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
use crate::a11y::{AccessibilityPolicy, Status, SymbolSet};
use crate::cancel::CancelToken;
use crate::format;
use crate::live::{LiveCoordinator, LiveError};
use crate::target::RenderTarget;
use crate::transfer::{bar_segments, effective_symbols, finish_line, keyed_style};
pub const STYLES: &[(&str, &str)] = &[
("countdown.remaining", "bold cyan"),
("countdown.bar", "cyan"),
("countdown.attempt", "bold"),
("countdown.reason", "none"),
("countdown.retry", "yellow"),
("countdown.failed", "bold red"),
("countdown.limited", "yellow"),
("countdown.quota", "dim"),
];
fn style(console: &Console, key: &str) -> Style {
keyed_style(console, STYLES, key)
}
pub fn remaining_label(left: Duration) -> String {
let secs = left
.as_secs()
.saturating_add(u64::from(left.subsec_nanos() > 0));
if secs < 60 {
format!("{secs}s")
} else {
format::duration(Duration::from_secs(secs))
}
}
fn dash(console: &Console) -> &'static str {
if console.ascii_only() {
" - "
} else {
" — "
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Backoff {
initial: Duration,
factor: f64,
max: Duration,
max_attempts: Option<u32>,
jitter: f64,
seed: u64,
}
impl Backoff {
pub fn new(initial: Duration) -> Self {
Backoff {
initial,
factor: 2.0,
max: Duration::from_secs(60),
max_attempts: None,
jitter: 0.0,
seed: 0,
}
}
pub fn factor(mut self, factor: f64) -> Self {
self.factor = if factor.is_finite() {
factor.max(1.0)
} else {
1.0
};
self
}
pub fn max(mut self, max: Duration) -> Self {
self.max = max;
self
}
pub fn attempts(mut self, attempts: u32) -> Self {
self.max_attempts = Some(attempts.max(1));
self
}
pub fn jitter(mut self, fraction: f64, seed: u64) -> Self {
self.jitter = if fraction.is_finite() {
fraction.clamp(0.0, 1.0)
} else {
0.0
};
self.seed = seed;
self
}
pub fn max_attempts(&self) -> Option<u32> {
self.max_attempts
}
pub fn delay(&self, attempt: u32) -> Option<Duration> {
let attempt = attempt.max(1);
if self.max_attempts.is_some_and(|max| attempt >= max) {
return None;
}
let exponent = i32::try_from(attempt - 1).unwrap_or(i32::MAX);
let base = if self.initial.is_zero() {
0.0
} else {
self.initial.as_secs_f64() * self.factor.powi(exponent)
};
let capped = base.min(self.max.as_secs_f64());
let unit = (splitmix64(self.seed ^ u64::from(attempt)) >> 11) as f64 / (1u64 << 53) as f64;
let delay = capped * (1.0 - self.jitter * unit);
let delay = Duration::try_from_secs_f64(delay).unwrap_or(self.max);
Some(delay.min(self.max))
}
pub fn delays(&self) -> impl Iterator<Item = Duration> + '_ {
(1..).map_while(move |attempt| self.delay(attempt))
}
pub fn status(&self, attempt: u32, reason: impl Into<String>) -> Option<RetryStatus> {
if attempt == 0 {
return None;
}
let mut status = RetryStatus::new(attempt).reason(reason);
if let Some(max) = self.max_attempts {
status = status.max_attempts(max);
}
if let Some(delay) = self.delay(attempt) {
status = status.retrying_in(delay);
}
Some(status)
}
}
fn splitmix64(seed: u64) -> u64 {
let mut z = seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
fn append_bar(
line: &mut Vec<Segment>,
console: &Console,
options: &ConsoleOptions,
total: Duration,
left: Duration,
width: usize,
) {
let used: usize = line.iter().map(Segment::cell_length).sum();
let width = width.min(options.max_width.saturating_sub(used + 2));
if width < 5 {
return;
}
line.push(Segment::new(" ", None));
let mut bar = CountdownBar::new(total, left)
.width(width)
.segments(console, options);
bar.truncate(bar.len().saturating_sub(2));
line.extend(bar);
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CountdownBar {
total: Duration,
remaining: Duration,
width: usize,
}
impl CountdownBar {
pub fn new(total: Duration, remaining: Duration) -> Self {
CountdownBar {
total,
remaining: remaining.min(total),
width: 20,
}
}
pub fn width(mut self, width: usize) -> Self {
self.width = width.max(3);
self
}
fn ratio(&self) -> f64 {
if self.total.is_zero() {
0.0
} else {
self.remaining.as_secs_f64() / self.total.as_secs_f64()
}
}
fn segments(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let mut out = bar_segments(
console,
options,
self.width.min(options.max_width),
Some(self.ratio()),
0.0,
Some(style(console, "countdown.bar")),
);
out.push(Segment::new(" ", None));
out.push(Segment::new(
remaining_label(self.remaining),
Some(style(console, "countdown.remaining")),
));
out
}
}
impl Renderable for CountdownBar {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
finish_line(self.segments(console, options), options.max_width)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RetryStatus {
attempt: u32,
max_attempts: Option<u32>,
reason: Option<String>,
remaining: Option<Duration>,
total: Option<Duration>,
bar_width: usize,
symbols: SymbolSet,
}
impl RetryStatus {
pub fn new(attempt: u32) -> Self {
RetryStatus {
attempt,
max_attempts: None,
reason: None,
remaining: None,
total: None,
bar_width: 20,
symbols: SymbolSet::Unicode,
}
}
pub fn max_attempts(mut self, max: u32) -> Self {
self.max_attempts = Some(max);
self
}
pub fn reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
pub fn retrying_in(mut self, left: Duration) -> Self {
self.remaining = Some(left);
self
}
pub fn bar(mut self, delay: Duration) -> Self {
self.total = Some(delay);
self
}
pub fn bar_width(mut self, width: usize) -> Self {
self.bar_width = width;
self
}
pub fn symbols(mut self, set: SymbolSet) -> Self {
self.symbols = set;
self
}
pub fn giving_up(&self) -> bool {
self.remaining.is_none()
}
pub fn at(&self, left: Duration) -> Self {
let mut next = self.clone();
next.remaining = Some(left);
next
}
pub fn status(&self) -> Status {
if self.giving_up() {
Status::Error
} else {
Status::Warning
}
}
}
impl Renderable for RetryStatus {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let set = effective_symbols(console, self.symbols);
let (marker_key, attempt) = match self.max_attempts {
Some(max) => (self.status(), format!("attempt {}/{max}", self.attempt)),
None => (self.status(), format!("attempt {}", self.attempt)),
};
let key = if marker_key == Status::Error {
"countdown.failed"
} else {
"countdown.retry"
};
let mut line = vec![
Segment::new(marker_key.symbol(set), Some(style(console, key))),
Segment::new(" ", None),
Segment::new(attempt, Some(style(console, "countdown.attempt"))),
Segment::new(" failed", None),
];
if let Some(reason) = &self.reason {
line.push(Segment::new(": ", None));
line.push(Segment::new(
reason.clone(),
Some(style(console, "countdown.reason")),
));
}
line.push(Segment::new(dash(console), None));
match self.remaining {
Some(left) => {
line.push(Segment::new("retrying in ", None));
line.push(Segment::new(
remaining_label(left),
Some(style(console, "countdown.remaining")),
));
if let Some(total) = self.total {
append_bar(&mut line, console, options, total, left, self.bar_width);
}
}
None => line.push(Segment::new("giving up", Some(style(console, key)))),
}
finish_line(line, options.max_width)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RateLimit {
resets_in: Duration,
limit: Option<u64>,
remaining: Option<u64>,
scope: Option<String>,
window: Option<Duration>,
bar_width: usize,
symbols: SymbolSet,
}
impl RateLimit {
pub fn new(resets_in: Duration) -> Self {
RateLimit {
resets_in,
limit: None,
remaining: None,
scope: None,
window: None,
bar_width: 20,
symbols: SymbolSet::Unicode,
}
}
pub fn limit(mut self, limit: u64) -> Self {
self.limit = Some(limit);
self
}
pub fn remaining(mut self, remaining: u64) -> Self {
self.remaining = Some(remaining);
self
}
pub fn scope(mut self, scope: impl Into<String>) -> Self {
self.scope = Some(scope.into());
self
}
pub fn bar(mut self, window: Duration) -> Self {
self.window = Some(window);
self
}
pub fn bar_width(mut self, width: usize) -> Self {
self.bar_width = width;
self
}
pub fn symbols(mut self, set: SymbolSet) -> Self {
self.symbols = set;
self
}
pub fn at(&self, left: Duration) -> Self {
let mut next = self.clone();
next.resets_in = left;
next
}
}
impl Renderable for RateLimit {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let set = effective_symbols(console, self.symbols);
let limited = style(console, "countdown.limited");
let mut line = vec![
Segment::new(Status::Warning.symbol(set), Some(limited.clone())),
Segment::new(" ", None),
Segment::new("rate limited", Some(limited)),
];
if let Some(scope) = &self.scope {
line.push(Segment::new(format!(" ({scope})"), None));
}
line.push(Segment::new(dash(console), None));
line.push(Segment::new("resets in ", None));
line.push(Segment::new(
format::clock(self.resets_in),
Some(style(console, "countdown.remaining")),
));
let quota = match (self.remaining, self.limit) {
(Some(left), Some(limit)) => Some(format!("{left}/{limit} left")),
(Some(left), None) => Some(format!("{left} left")),
(None, Some(limit)) => Some(format!("limit {limit}")),
(None, None) => None,
};
if let Some(quota) = quota {
line.push(Segment::new(
format!(" ({quota})"),
Some(style(console, "countdown.quota")),
));
}
if let Some(window) = self.window {
append_bar(
&mut line,
console,
options,
window,
self.resets_in,
self.bar_width,
);
}
finish_line(line, options.max_width)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Motion {
#[default]
Animated,
Static,
}
impl Motion {
pub fn for_target(target: &RenderTarget, policy: &AccessibilityPolicy) -> Motion {
use rich::protocol::RenderEnvironment;
if !target.capabilities().interactive || policy.no_animation || policy.reduced_motion {
Motion::Static
} else {
Motion::Animated
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WaitOutcome {
Elapsed,
Cancelled,
}
pub struct CountdownWait<'a> {
total: Duration,
tick: Duration,
cancel: Option<CancelToken>,
sleep: Box<dyn FnMut(Duration) + 'a>,
}
impl<'a> CountdownWait<'a> {
pub fn new(total: Duration) -> Self {
CountdownWait {
total,
tick: Duration::from_millis(250),
cancel: None,
sleep: Box::new(std::thread::sleep),
}
}
pub fn tick(mut self, tick: Duration) -> Self {
self.tick = tick.max(Duration::from_millis(1));
self
}
pub fn cancel(mut self, token: CancelToken) -> Self {
self.cancel = Some(token);
self
}
pub fn sleeper(mut self, sleep: impl FnMut(Duration) + 'a) -> Self {
self.sleep = Box::new(sleep);
self
}
pub fn run(mut self, mut on_tick: impl FnMut(Duration)) -> WaitOutcome {
let mut left = self.total;
loop {
if self.cancel.as_ref().is_some_and(CancelToken::is_cancelled) {
return WaitOutcome::Cancelled;
}
on_tick(left);
if left.is_zero() {
return WaitOutcome::Elapsed;
}
let step = self.tick.min(left);
(self.sleep)(step);
left -= step;
}
}
pub fn run_live<W: Write, R: Renderable>(
self,
live: &mut LiveCoordinator<W>,
target: &RenderTarget,
motion: Motion,
frame: impl Fn(Duration) -> R,
) -> Result<WaitOutcome, LiveError> {
if motion == Motion::Static {
live.print(&target.segments(&frame(self.total)))?;
return Ok(self.run(|_| {}));
}
let region = live.add(target.segments(&frame(self.total)))?;
let mut error = None;
let outcome = self.run(|left| {
if error.is_some() {
return;
}
let result = live
.update(region.clone(), target.segments(&frame(left)))
.and_then(|()| live.refresh());
if let Err(e) = result {
error = Some(e);
}
});
if let Some(e) = error {
return Err(e);
}
live.remove(region)?;
live.refresh()?;
Ok(outcome)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn labels_round_up_to_whole_seconds() {
assert_eq!(remaining_label(Duration::ZERO), "0s");
assert_eq!(remaining_label(Duration::from_millis(3100)), "4s");
assert_eq!(remaining_label(Duration::from_secs(65)), "1m 05s");
}
#[test]
fn jitter_stays_in_range_and_varies_by_attempt() {
let backoff = Backoff::new(Duration::from_secs(8))
.factor(1.0)
.jitter(1.0, 7);
let delays: Vec<Duration> = (1..=5).filter_map(|a| backoff.delay(a)).collect();
assert!(delays.iter().all(|d| *d <= Duration::from_secs(8)));
assert!(delays.windows(2).any(|w| w[0] != w[1]));
}
}