Skip to main content

decdn_cli/commands/
pull_progress.rs

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