decdn_cli/commands/pull_progress.rs
1//! Multi-bar progress for a bundle `decdn get`.
2//!
3//! A bundle pull fetches many blobs concurrently, so it renders a stack of
4//! per-file bars — one per pull that holds a `--jobs` slot, plus any that wait
5//! for a sibling's donor chunks — above one bottom **total** bar, all sharing
6//! an [`indicatif::MultiProgress`], under a one-line
7//! header naming the run and its download vs on-disk sizes. A finished file's bar
8//! clears, so only active pulls stay on screen. A blank **pad** bar under the
9//! total gains a row for each cleared file bar and gives one back for each new
10//! one, so the stack never shrinks: the container redraws in place, and an
11//! in-place redraw never erases the rows a shrunk stack leaves below it.
12//!
13//! The **total** bar meters the run's **download** — the bytes actually fetched
14//! after shared-chunk dedup (`download_bytes`), fixed up front as its denominator so
15//! it reads "downloaded of the whole download" from the start. Only delivered bytes
16//! fold into it (each file's delivery callback, capped at that file's download
17//! size); bytes spliced from disk are free and never inflate it, so its rate and ETA
18//! describe real transfer, not reconstruction. The header shows the download total
19//! beside the whole on-disk content size (`total_content_bytes`), so the dedup
20//! saving is visible.
21//!
22//! A **per-file** bar reads `downloaded/download-total (reconstructed)` plus a phase
23//! word. During the download it tracks the pull's delivered content against the
24//! blob's download size; when the download is done but a deferred chunk is still
25//! being fetched by a sibling it shows `pending siblings…`; during the whole-file
26//! verify it shows `reconstructing…` and fills with bytes hashed, so a multi-GB
27//! reconstruction never sits as a frozen row; before the first byte it shows
28//! `discovering…`. A per-file bar carries no rate/ETA: with `--jobs` pulls sharing
29//! one link, a single file's rate is only its share of the bandwidth and reads as
30//! stuck whenever another file has the bandwidth, while the total bar keeps moving.
31//!
32//! The total bar and header are shown only when the manifest declares sizes. A blob
33//! with no declared size contributes nothing to the denominator. If no kept entry
34//! (one that survives the include/exclude filters) declares a size, the total bar is
35//! omitted and only per-file bars render.
36//!
37//! In a terminal that supports the OSC 9;4 progress sequence (iTerm2, Ghostty,
38//! `WezTerm`, Windows Terminal, and others; detected by `anstyle-progress`, as Cargo
39//! does), the total bar's percent also drives the terminal's own progress
40//! indicator in the tab bar or dock. A run with no total bar shows it as
41//! indeterminate. The indicator is removed when the run finishes or the renderer
42//! drops.
43//!
44//! The whole renderer is silent — every bar a no-op, every file's delivery
45//! callback `None` — when stderr is not a terminal or the run is `--json`, so
46//! piped and scripted output carries no progress-bar bytes.
47
48use std::io::IsTerminal;
49use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
50use std::sync::{Arc, Mutex};
51use std::time::{Duration, Instant};
52
53use decdn_client::ProgressCallback;
54
55use super::fetch;
56use super::tab_progress::TabProgress;
57
58/// The label for a pull's bar: its first destination path, plus a `(+k more)`
59/// tail when the same blob lands at more than one path (a fetch-once hash group).
60pub(crate) fn file_label(paths: &[String]) -> String {
61 match paths {
62 [] => "(entry)".to_string(),
63 [only] => only.clone(),
64 [first, rest @ ..] => format!("{first} (+{} more)", rest.len()),
65 }
66}
67
68/// Steady-tick cadence shared by every bar.
69///
70/// Every bar here is born inside the [`indicatif::MultiProgress`] — `mp.add(..)`
71/// or `mp.insert_before(.., ProgressBar::new(..))` as one call — and is styled,
72/// labeled, sized, and ticked only afterwards. A `ProgressBar` outside a container
73/// draws itself straight to stderr on `set_prefix`, `set_length`, and every tick;
74/// the container has no record of that orphan line, so each later redraw moves the
75/// cursor up too few rows and the stale line scrolls into history instead of being
76/// overwritten. Nothing touches a bar before the container owns it.
77const TICK: Duration = Duration::from_millis(120);
78
79/// A bar of length `len` that clears its line when dropped unfinished, as the
80/// in-flight bars are when a Ctrl-C stops the run. Setting the finish mode draws
81/// nothing, so the bar is still untouched when the container takes it (see
82/// [`TICK`]).
83fn new_bar(len: u64) -> indicatif::ProgressBar {
84 indicatif::ProgressBar::new(len).with_finish(indicatif::ProgressFinish::AndClear)
85}
86
87/// The multi-bar renderer for one bundle pull. Disabled variants (no inner
88/// state) make every method a no-op and every bar handle silent.
89pub(crate) struct PullProgress {
90 inner: Option<Inner>,
91}
92
93/// The live rendering state, present only when bars are enabled.
94struct Inner {
95 /// The shared container every bar draws into — the process-global one when
96 /// logging is on (so bars and log lines coexist), else a standalone one.
97 mp: indicatif::MultiProgress,
98 /// The bottom total bar, in download bytes with a fixed denominator, carrying
99 /// the run's rate/ETA; per-file bars insert before it. `None` when the manifest
100 /// declares no sizes, in which case only per-file bars render.
101 total: Option<TotalBar>,
102 /// The blank bar under every other bar that holds the stack's height.
103 pad: Arc<PadBar>,
104 /// The terminal's tab-bar indicator; `None` when the terminal lacks OSC 9;4.
105 tab: Option<Arc<TabProgress>>,
106}
107
108/// The blank bottom bar that keeps the stack from shrinking. It renders one
109/// blank line plus one per row a cleared file bar freed, and a new file bar
110/// takes a freed row back. Its blank lines are written full width, so they
111/// overwrite whatever a taller stack left in those rows.
112struct PadBar {
113 /// The bar, styled to render only its `{msg}` of newlines.
114 bar: indicatif::ProgressBar,
115 /// The rows freed and not yet taken back. The lock also orders the
116 /// re-renders, so the last render shows the last count.
117 rows: Mutex<usize>,
118}
119
120impl PadBar {
121 /// Add the pad at the bottom of `mp` (see [`TICK`] for why the add comes
122 /// first).
123 fn add(mp: &indicatif::MultiProgress) -> Arc<Self> {
124 let bar = mp.add(new_bar(1));
125 bar.set_style(
126 indicatif::ProgressStyle::with_template("{msg}")
127 .unwrap_or_else(|_| indicatif::ProgressStyle::default_bar()),
128 );
129 Arc::new(Self {
130 bar,
131 rows: Mutex::new(0),
132 })
133 }
134
135 /// Hold the row of a file bar about to clear. Called before the clear, so
136 /// no frame draws the shorter stack.
137 fn free_row(&self) {
138 self.set_rows(|rows| rows + 1);
139 }
140
141 /// Give a freed row to a file bar just inserted. Called after the insert,
142 /// so no frame draws the shorter stack.
143 fn take_row(&self) {
144 self.set_rows(|rows| rows.saturating_sub(1));
145 }
146
147 fn set_rows(&self, f: impl FnOnce(usize) -> usize) {
148 if let Ok(mut rows) = self.rows.lock() {
149 *rows = f(*rows);
150 self.bar.set_message("\n".repeat(*rows));
151 }
152 }
153}
154
155/// A file bar's claim on one row of the stack. Releasing it, explicitly when
156/// the file finishes or on drop when the pull stops early, hands the row to
157/// the pad before the file bar clears.
158struct PadRow {
159 pad: Arc<PadBar>,
160 held: AtomicBool,
161}
162
163impl PadRow {
164 fn release(&self) {
165 if self.held.swap(false, Ordering::Relaxed) {
166 self.pad.free_row();
167 }
168 }
169}
170
171impl Drop for PadRow {
172 fn drop(&mut self) {
173 self.release();
174 }
175}
176
177impl Drop for Inner {
178 /// Remove the tab-bar indicator when the renderer drops, even if a per-file
179 /// callback still holds a clone of it: an early return must not leave a
180 /// stale percent in the tab, and a late update after the clear writes
181 /// nothing.
182 fn drop(&mut self) {
183 if let Some(tab) = &self.tab {
184 tab.clear();
185 }
186 }
187}
188
189/// The bottom total bar plus the rate meter behind its `{msg}`. Every downloaded
190/// byte folded into the total passes through [`inc`](Self::inc), which samples the
191/// meter; bytes spliced from disk never reach it, so its rate is a transfer rate.
192/// Cloning shares the same bar and meter.
193#[derive(Clone)]
194pub(crate) struct TotalBar {
195 /// The `indicatif` bar, in download bytes with a fixed length.
196 bar: indicatif::ProgressBar,
197 /// The whole-download rate estimate, sampled on every transferred increment.
198 speed: Arc<Mutex<fetch::SpeedState>>,
199 /// The terminal's tab-bar indicator, fed the bar's percent on every increment.
200 tab: Option<Arc<TabProgress>>,
201}
202
203impl TotalBar {
204 /// The total bar's style: byte counts, the rate/ETA `{msg}`, and a green bar.
205 fn style() -> indicatif::ProgressStyle {
206 indicatif::ProgressStyle::with_template(
207 // Rate/ETA come from `{msg}` (see `refresh_rate`), not the built-in
208 // `{bytes_per_sec}`/`{eta}` — those swing wildly on bursty arrival.
209 "{prefix:.bold} {bytes}/{total_bytes} {msg}[{wide_bar:.green}]",
210 )
211 .unwrap_or_else(|_| indicatif::ProgressStyle::default_bar())
212 .progress_chars("=>-")
213 }
214
215 /// Wrap an existing bar with a fresh meter, mirroring its percent into `tab`.
216 fn wrap(bar: indicatif::ProgressBar, tab: Option<Arc<TabProgress>>) -> Self {
217 Self {
218 bar,
219 speed: Arc::new(Mutex::new(fetch::SpeedState::default())),
220 tab,
221 }
222 }
223
224 /// Advance the total by `bytes` of transferred content, refresh the
225 /// rate/ETA from the new position, and update the tab-bar percent.
226 fn inc(&self, bytes: u64) {
227 self.bar.inc(bytes);
228 self.refresh_rate();
229 if let Some(tab) = &self.tab {
230 let position = self.bar.position();
231 tab.update(position, self.bar.length().unwrap_or(position));
232 }
233 }
234
235 /// Sample the meter at the bar's current position and rewrite the `{msg}` as
236 /// `(rate, ETA) `. A poisoned lock only costs this one refresh.
237 fn refresh_rate(&self) {
238 let position = self.bar.position();
239 if let Ok(mut s) = self.speed.lock() {
240 let bps = s.observe(Instant::now(), position);
241 let remaining = self
242 .bar
243 .length()
244 .unwrap_or(position)
245 .saturating_sub(position);
246 self.bar.set_message(format!(
247 "({}, {}) ",
248 fetch::fmt_rate(bps),
249 fetch::fmt_eta(remaining, bps)
250 ));
251 }
252 }
253
254 /// Clear the bar at the end of the run.
255 fn finish_and_clear(&self) {
256 self.bar.finish_and_clear();
257 }
258
259 /// The bar's content-byte position.
260 #[cfg(test)]
261 fn position(&self) -> u64 {
262 self.bar.position()
263 }
264}
265
266impl PullProgress {
267 /// Build the renderer. Returns a disabled renderer — no bars, no callbacks —
268 /// when stderr is not a terminal or the run is `--json`, so non-interactive
269 /// output is unchanged.
270 ///
271 /// `download_total` is the run's **download** size — the bytes actually fetched
272 /// after shared-chunk dedup (`download_bytes`) and the fixed total-bar
273 /// denominator. `content_total` is the whole on-disk size (`total_content_bytes`),
274 /// shown alongside in the header so the dedup saving is visible. `label` names
275 /// the run (the output directory). `download_total` `None`/0 (the manifest
276 /// declared no sizes) omits the total bar and renders only per-file bars.
277 pub(crate) fn new(
278 json: bool,
279 download_total: Option<u64>,
280 content_total: Option<u64>,
281 label: &str,
282 ) -> Self {
283 if json || !std::io::stderr().is_terminal() {
284 return Self::disabled();
285 }
286 let mp = crate::logging::progress_container();
287 // Header: what the run downloads vs what lands on disk after dedup. Printed
288 // once above the live bars; only when a download size is known and dedup
289 // actually saves bytes is the "on disk" figure worth showing. It goes out
290 // through `suspend` with its own newline: the container redraws in place
291 // (`logging::new_container`), and an in-place redraw returns to the start
292 // of a `println` line that has no bar under it yet and overwrites it.
293 if let Some(dl) = download_total.filter(|n| *n > 0) {
294 let header = match content_total.filter(|c| *c > dl) {
295 Some(content) => format!(
296 "{label} · {} to download · {} on disk",
297 indicatif::HumanBytes(dl),
298 indicatif::HumanBytes(content),
299 ),
300 None => format!("{label} · {} to download", indicatif::HumanBytes(dl)),
301 };
302 mp.suspend(|| eprintln!("{header}"));
303 }
304 let download_total = download_total.filter(|n| *n > 0);
305 let tab = TabProgress::detect(download_total.is_some());
306 let total = download_total.map(|len| Self::add_total_bar(&mp, len, tab.clone()));
307 let pad = PadBar::add(&mp);
308 Self {
309 inner: Some(Inner {
310 mp,
311 total,
312 pad,
313 tab,
314 }),
315 }
316 }
317
318 /// Build the bottom total bar with a fixed content-byte length inside the
319 /// container, then style, label, and tick it (see [`TICK`] for why the add
320 /// comes first).
321 fn add_total_bar(
322 mp: &indicatif::MultiProgress,
323 len: u64,
324 tab: Option<Arc<TabProgress>>,
325 ) -> TotalBar {
326 let bar = mp.add(new_bar(len));
327 bar.set_style(TotalBar::style());
328 bar.set_prefix("total");
329 bar.enable_steady_tick(TICK);
330 TotalBar::wrap(bar, tab)
331 }
332
333 /// A renderer that draws nothing and hands out silent bar handles.
334 pub(crate) const fn disabled() -> Self {
335 Self { inner: None }
336 }
337
338 /// The per-file bar style: a bold label, a free-form `{msg}` (the file's
339 /// `downloaded/download-total (reconstructed)` counts plus a phase word, all
340 /// formatted by [`FilePhase`]), and the wide bar. The counts live in `{msg}`
341 /// rather than the built-in `{bytes}/{total_bytes}` so the same row can read as
342 /// download progress and then as reconstruction without the bar's own length
343 /// having to mean two different things.
344 fn file_style() -> indicatif::ProgressStyle {
345 indicatif::ProgressStyle::with_template("{prefix:.bold} {msg}[{wide_bar:.cyan/blue}]")
346 .unwrap_or_else(|_| indicatif::ProgressStyle::default_bar())
347 .progress_chars("=>-")
348 }
349
350 /// Insert a new per-file bar above the total bar (or above the pad when there
351 /// is no total bar), style and label it, set its length to the file's download
352 /// size (the download-phase denominator), and start its steady tick — all
353 /// after the insert (see [`TICK`] for why nothing touches the bar before the
354 /// container owns it). The bar then takes a freed row back from the pad.
355 fn insert_file_bar(
356 i: &Inner,
357 label: &str,
358 download_total: u64,
359 ) -> (indicatif::ProgressBar, PadRow) {
360 let above = i.total.as_ref().map_or(&i.pad.bar, |total| &total.bar);
361 let bar = i.mp.insert_before(above, new_bar(download_total));
362 bar.set_style(Self::file_style());
363 bar.set_prefix(label.to_string());
364 bar.enable_steady_tick(TICK);
365 i.pad.take_row();
366 let row = PadRow {
367 pad: Arc::clone(&i.pad),
368 held: AtomicBool::new(true),
369 };
370 (bar, row)
371 }
372
373 /// A per-file bar for a whole-blob pull, labeled `label` and inserted above the
374 /// total bar. `download_total` is the blob's download (pay-now) bytes — the
375 /// bar's download-phase length and the `x/y` denominator; `reconstruct_total` is
376 /// the bytes it splices from disk — shown in parentheses and, during the verify,
377 /// the extra span the bar fills. Their sum is the blob's whole size. The bar's
378 /// download progress also folds into the total bar's download meter.
379 ///
380 /// When disabled the returned [`FileBar`] is silent: it has no delivery
381 /// callback, and ignores every report.
382 pub(crate) fn file_bar(
383 &self,
384 label: &str,
385 download_total: u64,
386 reconstruct_total: u64,
387 ) -> FileBar {
388 let Some(i) = &self.inner else {
389 return FileBar::disabled();
390 };
391 let (bar, row) = Self::insert_file_bar(i, label, download_total);
392 let phase = FilePhase {
393 bar,
394 download_total,
395 reconstruct_total,
396 };
397 phase.render(0, download_total, "");
398 // The delivery callback advances this file's download and folds the same
399 // downloaded bytes into the total bar's download meter (capped at the file's
400 // download size — a dedup entry's driver never delivers past its pay-now
401 // ranges, but the cap keeps the total honest regardless; an unsized entry's
402 // cap is 0, so it displays progress from `expected` but adds nothing).
403 let total = i.total.clone();
404 let cb_phase = phase.clone();
405 // The file's download bytes the total already holds: every fetch of the
406 // entry reports its whole position, so only the rise past it counts.
407 let prev = AtomicU64::new(0);
408 let cb = move |received: u64, expected: u64| {
409 cb_phase.download(received, expected);
410 if let Some(t) = &total {
411 let capped = received.min(cb_phase.download_total);
412 let last = prev.fetch_max(capped, Ordering::Relaxed);
413 if capped > last {
414 t.inc(capped - last);
415 }
416 }
417 };
418 FileBar {
419 row: Some(row),
420 phase: Some(phase),
421 cb: Some(Box::new(cb)),
422 }
423 }
424
425 /// Clear the total bar and remove the tab-bar indicator at the end of the run;
426 /// the command then prints its own summary line. A no-op when disabled.
427 pub(crate) fn finish(&self) {
428 let Some(i) = &self.inner else {
429 return;
430 };
431 if let Some(total) = &i.total {
432 total.finish_and_clear();
433 }
434 i.pad.bar.finish_and_clear();
435 if let Some(tab) = &i.tab {
436 tab.clear();
437 }
438 }
439}
440
441/// One file's (or fetch-once hash group's) live bar state: the bar, its download
442/// and reconstruct sizes, and the formatting of its `x/y (z) phase-word` message.
443/// Cloning shares the same underlying bar.
444#[derive(Clone)]
445struct FilePhase {
446 bar: indicatif::ProgressBar,
447 /// Download (pay-now) bytes — the `x/y` denominator and download-phase length.
448 download_total: u64,
449 /// Bytes spliced from disk — shown in `(…)` and, during verify, the extra span.
450 reconstruct_total: u64,
451}
452
453impl FilePhase {
454 /// Set the message to `downloaded/display-total[ (reconstructed)][ word]`, in
455 /// human byte units. `display_total` is the denominator to show — the download
456 /// total for a sized entry, or `0` for an entry with no known size yet, in which
457 /// case only the delivered count renders. `word` is a phase hint
458 /// (`pending siblings…`, `reconstructing…`, `discovering…`) or empty during a
459 /// plain download.
460 fn render(&self, downloaded: u64, display_total: u64, word: &str) {
461 let counts = if display_total > 0 {
462 format!(
463 "{}/{}",
464 indicatif::HumanBytes(downloaded.min(display_total)),
465 indicatif::HumanBytes(display_total),
466 )
467 } else {
468 indicatif::HumanBytes(downloaded).to_string()
469 };
470 let recon = if self.reconstruct_total > 0 {
471 format!(" ({})", indicatif::HumanBytes(self.reconstruct_total))
472 } else {
473 String::new()
474 };
475 let tail = if word.is_empty() {
476 String::new()
477 } else {
478 format!(" {word}")
479 };
480 self.bar.set_message(format!("{counts}{recon}{tail} "));
481 }
482
483 /// Advance the download: position tracks the delivered bytes and the counts
484 /// re-render. A sized entry meters against its download total; an entry with no
485 /// declared size (allowed by the manifest) has no download total, so it borrows
486 /// the stream's `expected` length for display only — it still contributes
487 /// nothing to the total bar (the fold is capped at the download total, which is
488 /// `0` here).
489 fn download(&self, received: u64, expected: u64) {
490 let display_total = if self.download_total > 0 {
491 self.download_total
492 } else {
493 expected
494 };
495 if display_total > 0 {
496 self.bar.set_length(display_total);
497 self.bar.set_position(received.min(display_total));
498 } else {
499 self.bar.set_position(received);
500 }
501 self.render(received, display_total, "");
502 }
503
504 /// Enter the `pending siblings…` wait: the download is done (bar full), and the
505 /// blob is now waiting for a sibling to register a deferred chunk.
506 fn pending(&self) {
507 self.bar.set_position(self.download_total);
508 self.render(
509 self.download_total,
510 self.download_total,
511 "pending siblings…",
512 );
513 }
514
515 /// Enter the `discovering…` phase: probing holders before any byte arrives.
516 fn discovering(&self) {
517 self.render(0, self.download_total, "discovering…");
518 }
519
520 /// Enter the `reconstructing…` verify: the bar now spans the whole blob size
521 /// (download + reconstruct) and fills with bytes hashed.
522 fn start_reconstructing(&self) {
523 self.bar
524 .set_length(self.download_total.saturating_add(self.reconstruct_total));
525 self.bar.set_position(0);
526 self.render(self.download_total, self.download_total, "reconstructing…");
527 }
528}
529
530/// One file's (or fetch-once hash group's) progress handle: its [`FilePhase`] plus
531/// the delivery callback that drives the download. Silent when the renderer is
532/// disabled.
533pub(crate) struct FileBar {
534 /// The bar's row in the stack; `None` when disabled. Declared first so a
535 /// drop hands the row to the pad before the bar's own drop clears it.
536 row: Option<PadRow>,
537 /// The live bar state; `None` when disabled.
538 phase: Option<FilePhase>,
539 /// The delivery callback handed to the fetch path; `None` when disabled.
540 cb: Option<Box<ProgressCallback>>,
541}
542
543impl FileBar {
544 /// A silent bar: no rendering, no callback.
545 fn disabled() -> Self {
546 Self {
547 row: None,
548 phase: None,
549 cb: None,
550 }
551 }
552
553 /// Show the `discovering…` phase (probing holders before any byte arrives).
554 pub(crate) fn set_discovering(&self) {
555 if let Some(p) = &self.phase {
556 p.discovering();
557 }
558 }
559
560 /// Show the `pending siblings…` phase (download done, waiting on a sibling to
561 /// register a deferred chunk).
562 pub(crate) fn set_pending(&self) {
563 if let Some(p) = &self.phase {
564 p.pending();
565 }
566 }
567}
568
569/// A file bar follows one blob of a [`decdn_client::bundle::BundlePuller`]
570/// pull.
571impl decdn_client::bundle::FileObserver for FileBar {
572 fn delivered(&self, received: u64, expected: u64) {
573 if let Some(cb) = &self.cb {
574 cb(received, expected);
575 }
576 }
577
578 fn discovering(&self) {
579 self.set_discovering();
580 }
581
582 fn pending(&self) {
583 self.set_pending();
584 }
585
586 fn reconstructing(&self) {
587 if let Some(p) = &self.phase {
588 p.start_reconstructing();
589 }
590 }
591
592 fn hashed(&self, bytes: u64) {
593 if let Some(p) = &self.phase {
594 p.bar.set_position(bytes);
595 }
596 }
597
598 fn finished(&self) {
599 if let Some(row) = &self.row {
600 row.release();
601 }
602 if let Some(p) = &self.phase {
603 p.bar.finish_and_clear();
604 }
605 }
606}
607
608#[cfg(test)]
609#[allow(
610 clippy::unwrap_used,
611 clippy::expect_used,
612 clippy::indexing_slicing,
613 clippy::panic
614)]
615mod tests;