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;