hf_fetch_model/progress.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Progress reporting for model downloads.
4//!
5//! [`ProgressEvent`] carries per-file and overall download status.
6//! When the `indicatif` feature is enabled, `IndicatifProgress`
7//! provides multi-progress bars out of the box.
8
9/// A progress event emitted during download.
10///
11/// Passed to the `on_progress` callback on [`crate::FetchConfig`].
12#[derive(Debug, Clone)]
13pub struct ProgressEvent {
14 /// The filename currently being downloaded.
15 pub filename: String,
16 /// Bytes downloaded so far for this file.
17 pub bytes_downloaded: u64,
18 /// Total size of this file in bytes (0 if unknown).
19 pub bytes_total: u64,
20 /// Download percentage for this file (0.0–100.0).
21 pub percent: f64,
22 /// Number of files still remaining (after this one).
23 pub files_remaining: usize,
24}
25
26/// Creates a [`ProgressEvent`] for a completed file.
27#[must_use]
28pub(crate) fn completed_event(filename: &str, size: u64, files_remaining: usize) -> ProgressEvent {
29 ProgressEvent {
30 filename: filename.to_owned(),
31 bytes_downloaded: size,
32 bytes_total: size,
33 percent: 100.0,
34 files_remaining,
35 }
36}
37
38/// Creates a [`ProgressEvent`] for an in-progress file (streaming update).
39#[must_use]
40pub(crate) fn streaming_event(
41 filename: &str,
42 bytes_downloaded: u64,
43 bytes_total: u64,
44 files_remaining: usize,
45) -> ProgressEvent {
46 #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
47 // CAST: u64 → f64, precision loss acceptable; values are display-only percentage scalars
48 let percent = if bytes_total > 0 {
49 (bytes_downloaded as f64 / bytes_total as f64) * 100.0
50 } else {
51 0.0
52 };
53 ProgressEvent {
54 filename: filename.to_owned(),
55 bytes_downloaded,
56 bytes_total,
57 percent,
58 files_remaining,
59 }
60}
61
62/// Multi-progress bar display using `indicatif`.
63///
64/// Available only when the `indicatif` feature is enabled.
65///
66/// # Example
67///
68/// ```rust,no_run
69/// # fn example() -> Result<(), hf_fetch_model::FetchError> {
70/// use hf_fetch_model::FetchConfig;
71/// # #[cfg(feature = "indicatif")]
72/// use hf_fetch_model::progress::IndicatifProgress;
73///
74/// # #[cfg(feature = "indicatif")]
75/// let progress = IndicatifProgress::new();
76/// let config = FetchConfig::builder()
77/// # ;
78/// # #[cfg(feature = "indicatif")]
79/// # let config = FetchConfig::builder()
80/// .on_progress(move |e| progress.handle(e))
81/// .build()?;
82/// # Ok(())
83/// # }
84/// ```
85#[cfg(feature = "indicatif")]
86pub struct IndicatifProgress {
87 // Multi-progress container for all bars.
88 multi: indicatif::MultiProgress,
89 // Overall file-count bar (always the last bar in the display).
90 overall: indicatif::ProgressBar,
91 /// Per-file progress bars, keyed by filename.
92 file_bars: std::sync::Mutex<std::collections::HashMap<String, indicatif::ProgressBar>>,
93 // Filenames already counted as complete (deduplicates chunked + orchestrator events).
94 completed_files: std::sync::Mutex<std::collections::HashSet<String>>,
95 // Guards against double-finish on drop.
96 finished: std::sync::atomic::AtomicBool,
97}
98
99#[cfg(feature = "indicatif")]
100impl IndicatifProgress {
101 /// Creates a new multi-progress bar display.
102 ///
103 /// Call [`IndicatifProgress::set_total_files`] once the file count is known.
104 #[must_use]
105 pub fn new() -> Self {
106 let multi = indicatif::MultiProgress::new();
107 let overall = multi.add(indicatif::ProgressBar::new(0));
108 overall.set_style(
109 indicatif::ProgressStyle::default_bar()
110 .template("{msg} [{bar:40.cyan/blue}] {pos}/{len} files")
111 .ok()
112 .unwrap_or_else(indicatif::ProgressStyle::default_bar)
113 .progress_chars("=> "),
114 );
115 overall.set_message("Overall");
116 Self {
117 multi,
118 overall,
119 file_bars: std::sync::Mutex::new(std::collections::HashMap::new()),
120 completed_files: std::sync::Mutex::new(std::collections::HashSet::new()),
121 finished: std::sync::atomic::AtomicBool::new(false),
122 }
123 }
124
125 /// Returns a reference to the underlying [`indicatif::MultiProgress`].
126 ///
127 /// Useful for adding custom progress bars alongside the built-in ones.
128 #[must_use]
129 pub fn multi(&self) -> &indicatif::MultiProgress {
130 &self.multi
131 }
132
133 /// Sets the total number of files to download.
134 pub fn set_total_files(&self, total: u64) {
135 self.overall.set_length(total);
136 }
137
138 /// Handles a [`ProgressEvent`], updating progress bars.
139 ///
140 /// For in-progress events, creates or updates a per-file progress bar
141 /// showing bytes downloaded, throughput, and ETA. On completion, the
142 /// per-file bar is finished and the overall file counter is incremented.
143 pub fn handle(&self, event: &ProgressEvent) {
144 if event.percent >= 100.0 {
145 // Remove and finish per-file bar if it exists.
146 if let Ok(mut bars) = self.file_bars.lock() {
147 if let Some(bar) = bars.remove(&event.filename) {
148 bar.finish_and_clear();
149 }
150 }
151 // Deduplicate: chunked downloads fire a streaming 100% event,
152 // then the orchestrator fires a completed_event for the same file.
153 let is_new = self
154 .completed_files
155 .lock()
156 .is_ok_and(|mut set| set.insert(event.filename.clone()));
157 if is_new {
158 // Derive total: completed so far + this file + remaining
159 // EXPLICIT: try_from for usize → u64 (infallible on 64-bit, safe fallback otherwise)
160 let remaining = u64::try_from(event.files_remaining).unwrap_or(u64::MAX);
161 let total = self.overall.position() + 1 + remaining;
162 self.overall.set_length(total);
163 self.overall.inc(1);
164 }
165 } else if event.bytes_total > 0 {
166 // In-progress streaming update — create or update per-file bar.
167 if let Ok(mut bars) = self.file_bars.lock() {
168 let bar = bars.entry(event.filename.clone()).or_insert_with(|| {
169 let pb = self.multi.insert_before(
170 &self.overall,
171 indicatif::ProgressBar::new(event.bytes_total),
172 );
173 pb.set_style(
174 indicatif::ProgressStyle::default_bar()
175 .template(
176 "{msg} [{bar:40.green/dim}] {bytes}/{total_bytes} {bytes_per_sec} ({eta})",
177 )
178 .ok()
179 .unwrap_or_else(indicatif::ProgressStyle::default_bar)
180 .progress_chars("=> "),
181 );
182 pb.set_message(event.filename.clone());
183 pb
184 });
185 bar.set_position(event.bytes_downloaded);
186 }
187 }
188 }
189
190 /// Finishes the progress bar, ensuring the final state is rendered.
191 ///
192 /// Called automatically on drop, but can be called explicitly for
193 /// immediate visual feedback.
194 pub fn finish(&self) {
195 if !self
196 .finished
197 .swap(true, std::sync::atomic::Ordering::Relaxed)
198 {
199 self.overall.finish();
200 }
201 }
202}
203
204#[cfg(feature = "indicatif")]
205impl Drop for IndicatifProgress {
206 fn drop(&mut self) {
207 self.finish();
208 }
209}
210
211#[cfg(feature = "indicatif")]
212impl Default for IndicatifProgress {
213 fn default() -> Self {
214 Self::new()
215 }
216}