1use std::io::Write;
37use std::time::Duration;
38
39use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
40
41use crate::a11y::{AccessibilityPolicy, Status, SymbolSet};
42use crate::cancel::CancelToken;
43use crate::format;
44use crate::live::{LiveCoordinator, LiveError};
45use crate::target::RenderTarget;
46use crate::transfer::{bar_segments, effective_symbols, finish_line, keyed_style};
47
48pub const STYLES: &[(&str, &str)] = &[
53 ("countdown.remaining", "bold cyan"),
54 ("countdown.bar", "cyan"),
55 ("countdown.attempt", "bold"),
56 ("countdown.reason", "none"),
57 ("countdown.retry", "yellow"),
58 ("countdown.failed", "bold red"),
59 ("countdown.limited", "yellow"),
60 ("countdown.quota", "dim"),
61];
62
63fn style(console: &Console, key: &str) -> Style {
64 keyed_style(console, STYLES, key)
65}
66
67pub fn remaining_label(left: Duration) -> String {
70 let secs = left
71 .as_secs()
72 .saturating_add(u64::from(left.subsec_nanos() > 0));
73 if secs < 60 {
74 format!("{secs}s")
75 } else {
76 format::duration(Duration::from_secs(secs))
77 }
78}
79
80fn dash(console: &Console) -> &'static str {
81 if console.ascii_only() { " - " } else { " — " }
82}
83
84#[derive(Clone, Debug, PartialEq)]
107pub struct Backoff {
108 initial: Duration,
109 factor: f64,
110 max: Duration,
111 max_attempts: Option<u32>,
112 jitter: f64,
113 seed: u64,
114}
115
116impl Backoff {
117 pub fn new(initial: Duration) -> Self {
120 Backoff {
121 initial,
122 factor: 2.0,
123 max: Duration::from_secs(60),
124 max_attempts: None,
125 jitter: 0.0,
126 seed: 0,
127 }
128 }
129
130 #[must_use]
132 pub fn factor(mut self, factor: f64) -> Self {
133 self.factor = if factor.is_finite() {
134 factor.max(1.0)
135 } else {
136 1.0
137 };
138 self
139 }
140
141 #[must_use]
143 pub fn max(mut self, max: Duration) -> Self {
144 self.max = max;
145 self
146 }
147
148 #[must_use]
150 pub fn attempts(mut self, attempts: u32) -> Self {
151 self.max_attempts = Some(attempts.max(1));
152 self
153 }
154
155 #[must_use]
160 pub fn jitter(mut self, fraction: f64, seed: u64) -> Self {
161 self.jitter = if fraction.is_finite() {
162 fraction.clamp(0.0, 1.0)
163 } else {
164 0.0
165 };
166 self.seed = seed;
167 self
168 }
169
170 pub fn max_attempts(&self) -> Option<u32> {
172 self.max_attempts
173 }
174
175 pub fn delay(&self, attempt: u32) -> Option<Duration> {
178 let attempt = attempt.max(1);
179 if self.max_attempts.is_some_and(|max| attempt >= max) {
180 return None;
181 }
182 let exponent = i32::try_from(attempt - 1).unwrap_or(i32::MAX);
184 let base = if self.initial.is_zero() {
185 0.0
186 } else {
187 self.initial.as_secs_f64() * self.factor.powi(exponent)
188 };
189 let capped = base.min(self.max.as_secs_f64());
190 let unit = (splitmix64(self.seed ^ u64::from(attempt)) >> 11) as f64 / (1u64 << 53) as f64;
191 let delay = capped * (1.0 - self.jitter * unit);
194 let delay = Duration::try_from_secs_f64(delay).unwrap_or(self.max);
195 Some(delay.min(self.max))
196 }
197
198 pub fn delays(&self) -> impl Iterator<Item = Duration> + '_ {
201 (1..).map_while(move |attempt| self.delay(attempt))
202 }
203
204 pub fn status(&self, attempt: u32, reason: impl Into<String>) -> Option<RetryStatus> {
207 if attempt == 0 {
208 return None;
209 }
210 let mut status = RetryStatus::new(attempt).reason(reason);
211 if let Some(max) = self.max_attempts {
212 status = status.max_attempts(max);
213 }
214 if let Some(delay) = self.delay(attempt) {
215 status = status.retrying_in(delay);
216 }
217 Some(status)
218 }
219}
220
221fn splitmix64(seed: u64) -> u64 {
223 let mut z = seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
224 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
225 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
226 z ^ (z >> 31)
227}
228
229fn append_bar(
233 line: &mut Vec<Segment>,
234 console: &Console,
235 options: &ConsoleOptions,
236 total: Duration,
237 left: Duration,
238 width: usize,
239) {
240 let used: usize = line.iter().map(Segment::cell_length).sum();
241 let width = width.min(options.max_width.saturating_sub(used + 2));
242 if width < 5 {
243 return;
244 }
245 line.push(Segment::new(" ", None));
246 let mut bar = CountdownBar::new(total, left)
247 .width(width)
248 .segments(console, options);
249 bar.truncate(bar.len().saturating_sub(2));
250 line.extend(bar);
251}
252
253#[derive(Clone, Debug, PartialEq, Eq)]
265pub struct CountdownBar {
266 total: Duration,
267 remaining: Duration,
268 width: usize,
269}
270
271impl CountdownBar {
272 pub fn new(total: Duration, remaining: Duration) -> Self {
274 CountdownBar {
275 total,
276 remaining: remaining.min(total),
277 width: 20,
278 }
279 }
280
281 #[must_use]
283 pub fn width(mut self, width: usize) -> Self {
284 self.width = width.max(3);
285 self
286 }
287
288 fn ratio(&self) -> f64 {
289 if self.total.is_zero() {
290 0.0
291 } else {
292 self.remaining.as_secs_f64() / self.total.as_secs_f64()
293 }
294 }
295
296 fn segments(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
297 let mut out = bar_segments(
298 console,
299 options,
300 self.width.min(options.max_width),
301 Some(self.ratio()),
302 0.0,
303 Some(style(console, "countdown.bar")),
304 );
305 out.push(Segment::new(" ", None));
306 out.push(Segment::new(
307 remaining_label(self.remaining),
308 Some(style(console, "countdown.remaining")),
309 ));
310 out
311 }
312}
313
314impl Renderable for CountdownBar {
315 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
316 finish_line(self.segments(console, options), options.max_width)
317 }
318}
319
320#[derive(Clone, Debug, PartialEq, Eq)]
323pub struct RetryStatus {
324 attempt: u32,
325 max_attempts: Option<u32>,
326 reason: Option<String>,
327 remaining: Option<Duration>,
328 total: Option<Duration>,
329 bar_width: usize,
330 symbols: SymbolSet,
331}
332
333impl RetryStatus {
334 pub fn new(attempt: u32) -> Self {
337 RetryStatus {
338 attempt,
339 max_attempts: None,
340 reason: None,
341 remaining: None,
342 total: None,
343 bar_width: 20,
344 symbols: SymbolSet::Unicode,
345 }
346 }
347
348 #[must_use]
350 pub fn max_attempts(mut self, max: u32) -> Self {
351 self.max_attempts = Some(max);
352 self
353 }
354
355 #[must_use]
357 pub fn reason(mut self, reason: impl Into<String>) -> Self {
358 self.reason = Some(reason.into());
359 self
360 }
361
362 #[must_use]
364 pub fn retrying_in(mut self, left: Duration) -> Self {
365 self.remaining = Some(left);
366 self
367 }
368
369 #[must_use]
372 pub fn bar(mut self, delay: Duration) -> Self {
373 self.total = Some(delay);
374 self
375 }
376
377 #[must_use]
379 pub fn bar_width(mut self, width: usize) -> Self {
380 self.bar_width = width;
381 self
382 }
383
384 #[must_use]
387 pub fn symbols(mut self, set: SymbolSet) -> Self {
388 self.symbols = set;
389 self
390 }
391
392 pub fn giving_up(&self) -> bool {
394 self.remaining.is_none()
395 }
396
397 #[must_use]
399 pub fn at(&self, left: Duration) -> Self {
400 let mut next = self.clone();
401 next.remaining = Some(left);
402 next
403 }
404
405 pub fn status(&self) -> Status {
407 if self.giving_up() {
408 Status::Error
409 } else {
410 Status::Warning
411 }
412 }
413}
414
415impl Renderable for RetryStatus {
416 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
417 let set = effective_symbols(console, self.symbols);
418 let (marker_key, attempt) = match self.max_attempts {
419 Some(max) => (self.status(), format!("attempt {}/{max}", self.attempt)),
420 None => (self.status(), format!("attempt {}", self.attempt)),
421 };
422 let key = if marker_key == Status::Error {
423 "countdown.failed"
424 } else {
425 "countdown.retry"
426 };
427 let mut line = vec![
428 Segment::new(marker_key.symbol(set), Some(style(console, key))),
429 Segment::new(" ", None),
430 Segment::new(attempt, Some(style(console, "countdown.attempt"))),
431 Segment::new(" failed", None),
432 ];
433 if let Some(reason) = &self.reason {
434 line.push(Segment::new(": ", None));
435 line.push(Segment::new(
436 reason.clone(),
437 Some(style(console, "countdown.reason")),
438 ));
439 }
440 line.push(Segment::new(dash(console), None));
441 match self.remaining {
442 Some(left) => {
443 line.push(Segment::new("retrying in ", None));
444 line.push(Segment::new(
445 remaining_label(left),
446 Some(style(console, "countdown.remaining")),
447 ));
448 if let Some(total) = self.total {
449 append_bar(&mut line, console, options, total, left, self.bar_width);
450 }
451 }
452 None => line.push(Segment::new("giving up", Some(style(console, key)))),
453 }
454 finish_line(line, options.max_width)
455 }
456}
457
458#[derive(Clone, Debug, PartialEq, Eq)]
470pub struct RateLimit {
471 resets_in: Duration,
472 limit: Option<u64>,
473 remaining: Option<u64>,
474 scope: Option<String>,
475 window: Option<Duration>,
476 bar_width: usize,
477 symbols: SymbolSet,
478}
479
480impl RateLimit {
481 pub fn new(resets_in: Duration) -> Self {
483 RateLimit {
484 resets_in,
485 limit: None,
486 remaining: None,
487 scope: None,
488 window: None,
489 bar_width: 20,
490 symbols: SymbolSet::Unicode,
491 }
492 }
493
494 #[must_use]
496 pub fn limit(mut self, limit: u64) -> Self {
497 self.limit = Some(limit);
498 self
499 }
500
501 #[must_use]
503 pub fn remaining(mut self, remaining: u64) -> Self {
504 self.remaining = Some(remaining);
505 self
506 }
507
508 #[must_use]
510 pub fn scope(mut self, scope: impl Into<String>) -> Self {
511 self.scope = Some(scope.into());
512 self
513 }
514
515 #[must_use]
517 pub fn bar(mut self, window: Duration) -> Self {
518 self.window = Some(window);
519 self
520 }
521
522 #[must_use]
524 pub fn bar_width(mut self, width: usize) -> Self {
525 self.bar_width = width;
526 self
527 }
528
529 #[must_use]
532 pub fn symbols(mut self, set: SymbolSet) -> Self {
533 self.symbols = set;
534 self
535 }
536
537 #[must_use]
539 pub fn at(&self, left: Duration) -> Self {
540 let mut next = self.clone();
541 next.resets_in = left;
542 next
543 }
544}
545
546impl Renderable for RateLimit {
547 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
548 let set = effective_symbols(console, self.symbols);
549 let limited = style(console, "countdown.limited");
550 let mut line = vec![
551 Segment::new(Status::Warning.symbol(set), Some(limited.clone())),
552 Segment::new(" ", None),
553 Segment::new("rate limited", Some(limited)),
554 ];
555 if let Some(scope) = &self.scope {
556 line.push(Segment::new(format!(" ({scope})"), None));
557 }
558 line.push(Segment::new(dash(console), None));
559 line.push(Segment::new("resets in ", None));
560 line.push(Segment::new(
561 format::clock(self.resets_in),
562 Some(style(console, "countdown.remaining")),
563 ));
564 let quota = match (self.remaining, self.limit) {
565 (Some(left), Some(limit)) => Some(format!("{left}/{limit} left")),
566 (Some(left), None) => Some(format!("{left} left")),
567 (None, Some(limit)) => Some(format!("limit {limit}")),
568 (None, None) => None,
569 };
570 if let Some(quota) = quota {
571 line.push(Segment::new(
572 format!(" ({quota})"),
573 Some(style(console, "countdown.quota")),
574 ));
575 }
576 if let Some(window) = self.window {
577 append_bar(
578 &mut line,
579 console,
580 options,
581 window,
582 self.resets_in,
583 self.bar_width,
584 );
585 }
586 finish_line(line, options.max_width)
587 }
588}
589
590#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
592pub enum Motion {
593 #[default]
595 Animated,
596 Static,
598}
599
600impl Motion {
601 pub fn for_target(target: &RenderTarget, policy: &AccessibilityPolicy) -> Motion {
604 use rich::protocol::RenderEnvironment;
605 if !target.capabilities().interactive || policy.no_animation || policy.reduced_motion {
606 Motion::Static
607 } else {
608 Motion::Animated
609 }
610 }
611}
612
613#[derive(Clone, Copy, Debug, PartialEq, Eq)]
615pub enum WaitOutcome {
616 Elapsed,
618 Cancelled,
620}
621
622pub struct CountdownWait<'a> {
641 total: Duration,
642 tick: Duration,
643 cancel: Option<CancelToken>,
644 sleep: Box<dyn FnMut(Duration) + 'a>,
645}
646
647impl<'a> CountdownWait<'a> {
648 pub fn new(total: Duration) -> Self {
650 CountdownWait {
651 total,
652 tick: Duration::from_millis(250),
653 cancel: None,
654 sleep: Box::new(std::thread::sleep),
655 }
656 }
657
658 #[must_use]
660 pub fn tick(mut self, tick: Duration) -> Self {
661 self.tick = tick.max(Duration::from_millis(1));
662 self
663 }
664
665 #[must_use]
667 pub fn cancel(mut self, token: CancelToken) -> Self {
668 self.cancel = Some(token);
669 self
670 }
671
672 #[must_use]
674 pub fn sleeper(mut self, sleep: impl FnMut(Duration) + 'a) -> Self {
675 self.sleep = Box::new(sleep);
676 self
677 }
678
679 pub fn run(mut self, mut on_tick: impl FnMut(Duration)) -> WaitOutcome {
682 let mut left = self.total;
683 loop {
684 if self.cancel.as_ref().is_some_and(CancelToken::is_cancelled) {
685 return WaitOutcome::Cancelled;
686 }
687 on_tick(left);
688 if left.is_zero() {
689 return WaitOutcome::Elapsed;
690 }
691 let step = self.tick.min(left);
692 (self.sleep)(step);
693 left -= step;
694 }
695 }
696
697 pub fn run_live<W: Write, R: Renderable>(
703 self,
704 live: &mut LiveCoordinator<W>,
705 target: &RenderTarget,
706 motion: Motion,
707 frame: impl Fn(Duration) -> R,
708 ) -> Result<WaitOutcome, LiveError> {
709 if motion == Motion::Static {
710 live.print(&target.segments(&frame(self.total)))?;
711 return Ok(self.run(|_| {}));
712 }
713 let region = live.add(target.segments(&frame(self.total)))?;
714 let mut error = None;
715 let outcome = self.run(|left| {
716 if error.is_some() {
717 return;
718 }
719 let result = live
720 .update(region.clone(), target.segments(&frame(left)))
721 .and_then(|()| live.refresh());
722 if let Err(e) = result {
723 error = Some(e);
724 }
725 });
726 if let Some(e) = error {
727 return Err(e);
728 }
729 live.remove(region)?;
730 live.refresh()?;
731 Ok(outcome)
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738
739 #[test]
740 fn labels_round_up_to_whole_seconds() {
741 assert_eq!(remaining_label(Duration::ZERO), "0s");
742 assert_eq!(remaining_label(Duration::from_millis(3100)), "4s");
743 assert_eq!(remaining_label(Duration::from_secs(65)), "1m 05s");
744 }
745
746 #[test]
747 fn jitter_stays_in_range_and_varies_by_attempt() {
748 let backoff = Backoff::new(Duration::from_secs(8))
749 .factor(1.0)
750 .jitter(1.0, 7);
751 let delays: Vec<Duration> = (1..=5).filter_map(|a| backoff.delay(a)).collect();
752 assert!(delays.iter().all(|d| *d <= Duration::from_secs(8)));
753 assert!(delays.windows(2).any(|w| w[0] != w[1]));
754 }
755}