Skip to main content

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;