hf_fetch_model/lib.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! # hf-fetch-model
4//!
5//! Fast `HuggingFace` model downloads for Rust.
6//!
7//! An embeddable library for downloading `HuggingFace` model repositories
8//! with maximum throughput. Wraps [`hf_hub`] and adds repo-level orchestration.
9//!
10//! ## Quick Start
11//!
12//! ```rust,no_run
13//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
14//! let outcome = hf_fetch_model::download("julien-c/dummy-unknown".to_owned()).await?;
15//! println!("Model at: {}", outcome.inner().display());
16//! # Ok(())
17//! # }
18//! ```
19//!
20//! ## Configured Download
21//!
22//! ```rust,no_run
23//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
24//! use hf_fetch_model::FetchConfig;
25//!
26//! let config = FetchConfig::builder()
27//! .filter("*.safetensors")
28//! .filter("*.json")
29//! .on_progress(|e| {
30//! println!("{}: {:.1}%", e.filename, e.percent);
31//! })
32//! .build()?;
33//!
34//! let outcome = hf_fetch_model::download_with_config(
35//! "google/gemma-2-2b".to_owned(),
36//! &config,
37//! ).await?;
38//! // outcome.is_cached() tells you if it came from local cache
39//! let path = outcome.into_inner();
40//! # Ok(())
41//! # }
42//! ```
43//!
44//! ## Inspect Before Downloading
45//!
46//! Read tensor metadata from `.safetensors` headers via HTTP Range requests —
47//! no weight data downloaded. Sharded repos (those with
48//! `model.safetensors.index.json`) work transparently —
49//! [`inspect::inspect_repo_safetensors`] reads every shard's header in parallel
50//! and returns a flat per-file result list. See
51//! [`examples/candle_inspect.rs`](https://github.com/PCfVW/hf-fetch-model/blob/main/examples/candle_inspect.rs)
52//! for a runnable example, or the
53//! [Inspect tutorial](https://github.com/PCfVW/hf-fetch-model/blob/main/docs/tutorials/inspect-before-downloading.md)
54//! for a narrative walkthrough.
55//!
56//! ```rust,no_run
57//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
58//! let results = hf_fetch_model::inspect::inspect_repo_safetensors(
59//! "EleutherAI/pythia-1.4b", None, None,
60//! ).await?;
61//!
62//! for (filename, header, _source) in &results {
63//! println!("{filename}: {} tensors", header.tensors.len());
64//! }
65//! # Ok(())
66//! # }
67//! ```
68//!
69//! The CLI also exposes `hf-fm inspect <repo> [FILE] --check-gpu [N]` (v0.10.1)
70//! to print a one-line GPU-fit verdict against device `N` (default 0) using
71//! the `hypomnesis` crate (NVML on Linux/Windows, DXGI on Windows). The
72//! verdict is a binary-only feature today; no library equivalent is exposed
73//! — depend on `hypomnesis` directly if you need the device-info numbers
74//! from library code.
75//!
76//! ## Cached-file Inspection
77//!
78//! Beyond the remote-or-cached `.safetensors` path above,
79//! [`inspect::inspect_gguf_cached`] (v0.10.2),
80//! [`inspect::inspect_npz_cached`], and [`inspect::inspect_pth_cached`]
81//! (both v0.10.3) extend inspect to `GGUF` / `NumPy` `.npz` / `PyTorch`
82//! `.pth` files in the local cache via the `anamnesis` parser crate. All
83//! four formats return the same format-agnostic
84//! [`inspect::SafetensorsHeaderInfo`] shape, so downstream pipeline steps
85//! (filter, tree, dtypes aggregation) work uniformly across formats.
86//!
87//! For cached `.safetensors` files, v0.10.3 also surfaces quantization
88//! detection. When [`inspect::inspect_safetensors_local`] sees a quantized
89//! header (`FP8` variants, `GPTQ`, `AWQ`, `BnB-NF4`, `BnB-INT8`), it
90//! populates the new [`inspect::QuantInfo`] field with the scheme name and
91//! both stored + dequantised byte sizes. Unquantized safetensors and
92//! non-safetensors formats leave `quant_info` as `None`.
93//!
94//! ```rust,no_run
95//! # fn example() -> Result<(), hf_fetch_model::FetchError> {
96//! use hf_fetch_model::inspect;
97//! use std::path::Path;
98//!
99//! let header = inspect::inspect_safetensors_local(
100//! Path::new("/path/to/cached/file.safetensors"),
101//! )?;
102//! if let Some(q) = &header.quant_info {
103//! println!(
104//! "Quantized as {}: {} stored -> {} dequantised",
105//! q.scheme, q.stored_bytes, q.dequantized_bytes,
106//! );
107//! }
108//! # Ok(())
109//! # }
110//! ```
111//!
112//! Remote inspect for `GGUF` / `NPZ` / `PTH` (via HTTP Range, without
113//! going through the cache) is planned for v0.11; until then those formats
114//! error early with a "pass --cached after downloading" recovery hint.
115//!
116//! ## `HuggingFace` Cache
117//!
118//! Downloaded files are stored in the standard `HuggingFace` cache directory
119//! (`~/.cache/huggingface/hub/`), ensuring compatibility with Python tooling.
120//!
121//! ## Cache Management
122//!
123//! v0.10.0 adds library APIs for inspecting, verifying, and pruning the local
124//! cache. [`cache::cache_summary`] enumerates every cached repo with size and
125//! file counts; [`cache::repo_status`] gives a per-file `Complete` / `Partial` /
126//! `Missing` breakdown for one repo; [`cache::verify_cache`] re-checks `SHA256`
127//! digests of cached files against `HuggingFace` LFS metadata; and
128//! [`cache::find_partial_files`] locates `.chunked.part` orphans from
129//! interrupted downloads.
130//!
131//! For long verifications (multi-GiB safetensors files), drive
132//! [`cache::verify_cache_with_progress`] with an [`Fn`] callback that receives
133//! [`cache::VerifyEvent`]s so a CLI or GUI can render a spinner or progress
134//! bar without polling.
135//!
136//! ```rust,no_run
137//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
138//! use hf_fetch_model::cache::{self, VerifyStatus};
139//!
140//! let results = cache::verify_cache("google/gemma-2-2b-it", None, None).await?;
141//! let ok = results
142//! .iter()
143//! .filter(|r| matches!(r.status, VerifyStatus::Ok))
144//! .count();
145//! let mismatch = results
146//! .iter()
147//! .filter(|r| matches!(r.status, VerifyStatus::Mismatch { .. }))
148//! .count();
149//! println!("{}/{} files verified, {} mismatches", ok, results.len(), mismatch);
150//! # Ok(())
151//! # }
152//! ```
153//!
154//! ## Download Durability
155//!
156//! Multi-connection downloads survive interruption. When a download is
157//! aborted by [`FetchConfigBuilder::timeout_per_file`] (default 300 s),
158//! Ctrl-C, panic, or a transient chunk error, the partial `.chunked.part`
159//! file plus a small per-chunk progress sidecar are kept on disk. The next
160//! call to [`download_with_config`] for the same file picks up where it
161//! stopped — each parallel chunk sends a fresh `Range` request that skips
162//! the bytes it already has — provided the upstream etag still matches.
163//! On etag change, schema-version mismatch, or a different
164//! [`FetchConfigBuilder::connections_per_file`] count, the partial is
165//! discarded and a fresh download starts.
166//!
167//! For slow connections on multi-GiB files, raise the per-file budget to
168//! match real throughput:
169//!
170//! ```rust,no_run
171//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
172//! use std::time::Duration;
173//! use hf_fetch_model::FetchConfig;
174//!
175//! let config = FetchConfig::builder()
176//! .timeout_per_file(Duration::from_secs(1800))
177//! .build()?;
178//! # let _ = hf_fetch_model::download_with_config(
179//! # "google/gemma-4-E2B-it".to_owned(),
180//! # &config,
181//! # ).await?;
182//! # Ok(())
183//! # }
184//! ```
185//!
186//! ## Authentication
187//!
188//! Set the `HF_TOKEN` environment variable to access private or gated models,
189//! or use [`FetchConfig::builder().token()`](FetchConfigBuilder::token).
190
191pub mod cache;
192pub mod cache_layout;
193pub mod checksum;
194mod chunked;
195mod chunked_state;
196pub mod config;
197pub mod discover;
198pub mod download;
199pub mod error;
200pub mod inspect;
201pub mod plan;
202pub mod progress;
203pub mod repo;
204mod retry;
205
206pub use chunked::build_client;
207pub use config::{
208 compile_glob_patterns, file_matches, has_glob_chars, FetchConfig, FetchConfigBuilder, Filter,
209};
210pub use discover::{DiscoveredFamily, GateStatus, ModelCardMetadata, SearchResult};
211pub use download::DownloadOutcome;
212pub use error::{FetchError, FileFailure};
213pub use inspect::AdapterConfig;
214pub use plan::{download_plan, DownloadPlan, FilePlan};
215pub use progress::{ProgressEvent, ProgressReceiver};
216
217use std::collections::HashMap;
218use std::path::PathBuf;
219
220use hf_hub::{Repo, RepoType};
221
222/// Pre-flight check for gated model access.
223///
224/// Two cases:
225/// - **No token**: checks the model metadata (unauthenticated) for gating
226/// status and rejects with a clear message if gated.
227/// - **Token present**: if the model is gated, makes one authenticated
228/// metadata request to verify the token actually grants access. Catches
229/// invalid tokens and unaccepted licenses before the download starts.
230///
231/// If the metadata request itself fails (network error, private repo),
232/// the check is silently skipped so that normal download error handling
233/// can take over.
234async fn preflight_gated_check(repo_id: &str, config: &FetchConfig) -> Result<(), FetchError> {
235 // Best-effort: if the metadata call fails, let the download proceed.
236 let Ok(metadata) = discover::fetch_model_card(repo_id).await else {
237 return Ok(());
238 };
239
240 if !metadata.gated.is_gated() {
241 return Ok(());
242 }
243
244 // Model is gated — check auth.
245 if config.token.is_none() {
246 return Err(FetchError::Auth {
247 reason: format!(
248 "{repo_id} is a gated model — accept the license at \
249 https://huggingface.co/{repo_id} and set HF_TOKEN or pass --token"
250 ),
251 });
252 }
253
254 // Token is present — verify it grants access with a lightweight probe.
255 let probe_client = chunked::build_client(config.token.as_deref())?;
256 let probe = repo::list_repo_files_with_metadata(
257 repo_id,
258 config.token.as_deref(),
259 config.revision.as_deref(),
260 &probe_client,
261 )
262 .await;
263
264 if let Err(ref e) = probe {
265 // BORROW: explicit .to_string() for error Display formatting
266 let msg = e.to_string();
267 if msg.contains("401") || msg.contains("403") {
268 return Err(FetchError::Auth {
269 reason: format!(
270 "{repo_id} is a gated model and your token was rejected — \
271 accept the license at https://huggingface.co/{repo_id} \
272 and check that your token is valid"
273 ),
274 });
275 }
276 }
277
278 Ok(())
279}
280
281/// Downloads all files from a `HuggingFace` model repository.
282///
283/// Uses high-throughput mode for maximum download speed, including
284/// auto-tuned concurrency, chunked multi-connection downloads for large
285/// files, and plan-optimized settings based on file size distribution.
286/// Files are stored in the standard `HuggingFace` cache layout
287/// (`~/.cache/huggingface/hub/`).
288///
289/// Authentication is handled via the `HF_TOKEN` environment variable when set.
290///
291/// For filtering, progress, and other options, use [`download_with_config()`].
292///
293/// # Arguments
294///
295/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
296///
297/// # Returns
298///
299/// The path to the snapshot directory containing all downloaded files.
300///
301/// # Errors
302///
303/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
304/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
305/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
306/// * [`FetchError::InvalidPattern`] — if the default config fails to build (should not happen).
307pub async fn download(repo_id: String) -> Result<DownloadOutcome<PathBuf>, FetchError> {
308 let config = FetchConfig::builder().build()?;
309 download_with_config(repo_id, &config).await
310}
311
312/// Downloads files from a `HuggingFace` model repository using the given configuration.
313///
314/// Supports filtering, progress reporting, custom revision, authentication,
315/// and concurrency settings via [`FetchConfig`].
316///
317/// # Arguments
318///
319/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
320/// * `config` — Download configuration (see [`FetchConfig::builder()`]).
321///
322/// # Returns
323///
324/// The path to the snapshot directory containing all downloaded files.
325///
326/// # Errors
327///
328/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
329/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
330/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
331pub async fn download_with_config(
332 repo_id: String,
333 config: &FetchConfig,
334) -> Result<DownloadOutcome<PathBuf>, FetchError> {
335 // BORROW: explicit .as_str() instead of Deref coercion
336 preflight_gated_check(repo_id.as_str(), config).await?;
337
338 let mut builder = hf_hub::api::tokio::ApiBuilder::new().high();
339
340 if let Some(ref token) = config.token {
341 // BORROW: explicit .clone() to pass owned String
342 builder = builder.with_token(Some(token.clone()));
343 }
344
345 if let Some(ref dir) = config.output_dir {
346 // BORROW: explicit .clone() for owned PathBuf
347 builder = builder.with_cache_dir(dir.clone());
348 }
349
350 let api = builder.build().map_err(FetchError::Api)?;
351
352 let hf_repo = match config.revision {
353 Some(ref rev) => {
354 // BORROW: explicit .clone() for owned String arguments
355 Repo::with_revision(repo_id.clone(), RepoType::Model, rev.clone())
356 }
357 None => Repo::new(repo_id.clone(), RepoType::Model),
358 };
359
360 let repo = api.repo(hf_repo);
361 download::download_all_files(repo, repo_id, Some(config)).await
362}
363
364/// Blocking version of [`download()`] for non-async callers.
365///
366/// Creates a Tokio runtime internally. Do not call from within
367/// an existing async context (use [`download()`] instead).
368///
369/// # Errors
370///
371/// Same as [`download()`].
372pub fn download_blocking(repo_id: String) -> Result<DownloadOutcome<PathBuf>, FetchError> {
373 let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
374 path: PathBuf::from("<runtime>"),
375 source: e,
376 })?;
377 rt.block_on(download(repo_id))
378}
379
380/// Blocking version of [`download_with_config()`] for non-async callers.
381///
382/// Creates a Tokio runtime internally. Do not call from within
383/// an existing async context (use [`download_with_config()`] instead).
384///
385/// # Errors
386///
387/// Same as [`download_with_config()`].
388pub fn download_with_config_blocking(
389 repo_id: String,
390 config: &FetchConfig,
391) -> Result<DownloadOutcome<PathBuf>, FetchError> {
392 let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
393 path: PathBuf::from("<runtime>"),
394 source: e,
395 })?;
396 rt.block_on(download_with_config(repo_id, config))
397}
398
399/// Downloads all files from a `HuggingFace` model repository and returns
400/// a filename → path map.
401///
402/// Each key is the relative filename within the repository (e.g.,
403/// `"config.json"`, `"model.safetensors"`), and each value is the
404/// absolute local path to the downloaded file.
405///
406/// Uses the same high-throughput defaults as [`download()`]: auto-tuned
407/// concurrency and chunked multi-connection downloads for large files.
408///
409/// For filtering, progress, and other options, use
410/// [`download_files_with_config()`].
411///
412/// # Arguments
413///
414/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
415///
416/// # Errors
417///
418/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
419/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
420/// * [`FetchError::InvalidPattern`] — if the default config fails to build (should not happen).
421pub async fn download_files(
422 repo_id: String,
423) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
424 let config = FetchConfig::builder().build()?;
425 download_files_with_config(repo_id, &config).await
426}
427
428/// Downloads files from a `HuggingFace` model repository using the given
429/// configuration and returns a filename → path map.
430///
431/// Each key is the relative filename within the repository (e.g.,
432/// `"config.json"`, `"model.safetensors"`), and each value is the
433/// absolute local path to the downloaded file.
434///
435/// # Arguments
436///
437/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
438/// * `config` — Download configuration (see [`FetchConfig::builder()`]).
439///
440/// # Errors
441///
442/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
443/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
444/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
445pub async fn download_files_with_config(
446 repo_id: String,
447 config: &FetchConfig,
448) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
449 // BORROW: explicit .as_str() instead of Deref coercion
450 preflight_gated_check(repo_id.as_str(), config).await?;
451
452 let mut builder = hf_hub::api::tokio::ApiBuilder::new().high();
453
454 if let Some(ref token) = config.token {
455 // BORROW: explicit .clone() to pass owned String
456 builder = builder.with_token(Some(token.clone()));
457 }
458
459 if let Some(ref dir) = config.output_dir {
460 // BORROW: explicit .clone() for owned PathBuf
461 builder = builder.with_cache_dir(dir.clone());
462 }
463
464 let api = builder.build().map_err(FetchError::Api)?;
465
466 let hf_repo = match config.revision {
467 Some(ref rev) => {
468 // BORROW: explicit .clone() for owned String arguments
469 Repo::with_revision(repo_id.clone(), RepoType::Model, rev.clone())
470 }
471 None => Repo::new(repo_id.clone(), RepoType::Model),
472 };
473
474 let repo = api.repo(hf_repo);
475 download::download_all_files_map(repo, repo_id, Some(config)).await
476}
477
478/// Blocking version of [`download_files()`] for non-async callers.
479///
480/// Creates a Tokio runtime internally. Do not call from within
481/// an existing async context (use [`download_files()`] instead).
482///
483/// # Errors
484///
485/// Same as [`download_files()`].
486pub fn download_files_blocking(
487 repo_id: String,
488) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
489 let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
490 path: PathBuf::from("<runtime>"),
491 source: e,
492 })?;
493 rt.block_on(download_files(repo_id))
494}
495
496/// Downloads a single file from a `HuggingFace` model repository.
497///
498/// Returns the local cache path. If the file is already cached (and
499/// checksums match when `verify_checksums` is enabled), the download
500/// is skipped and the cached path is returned immediately.
501///
502/// Files at or above [`FetchConfig`]'s `chunk_threshold` (auto-tuned by
503/// the download plan optimizer, or 100 MiB fallback) are downloaded using
504/// multiple parallel HTTP Range connections (`connections_per_file`,
505/// auto-tuned or 8 fallback). Smaller files use a single connection.
506///
507/// # Arguments
508///
509/// * `repo_id` — Repository identifier (e.g., `"mntss/clt-gemma-2-2b-426k"`).
510/// * `filename` — Exact filename within the repository (e.g., `"W_enc_5.safetensors"`).
511/// * `config` — Shared configuration for auth, progress, checksums, retries, and chunking.
512///
513/// # Errors
514///
515/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
516/// * [`FetchError::Http`] — if the file does not exist in the repository.
517/// * [`FetchError::Api`] — on download failure (after retries).
518/// * [`FetchError::Checksum`] — if verification is enabled and fails.
519pub async fn download_file(
520 repo_id: String,
521 filename: &str,
522 config: &FetchConfig,
523) -> Result<DownloadOutcome<PathBuf>, FetchError> {
524 // BORROW: explicit .as_str() instead of Deref coercion
525 preflight_gated_check(repo_id.as_str(), config).await?;
526
527 let mut builder = hf_hub::api::tokio::ApiBuilder::new().high();
528
529 if let Some(ref token) = config.token {
530 // BORROW: explicit .clone() to pass owned String
531 builder = builder.with_token(Some(token.clone()));
532 }
533
534 if let Some(ref dir) = config.output_dir {
535 // BORROW: explicit .clone() for owned PathBuf
536 builder = builder.with_cache_dir(dir.clone());
537 }
538
539 let api = builder.build().map_err(FetchError::Api)?;
540
541 let hf_repo = match config.revision {
542 Some(ref rev) => {
543 // BORROW: explicit .clone() for owned String arguments
544 Repo::with_revision(repo_id.clone(), RepoType::Model, rev.clone())
545 }
546 None => Repo::new(repo_id.clone(), RepoType::Model),
547 };
548
549 let repo = api.repo(hf_repo);
550 download::download_file_by_name(repo, repo_id, filename, config).await
551}
552
553/// Blocking version of [`download_file()`] for non-async callers.
554///
555/// Creates a Tokio runtime internally. Do not call from within
556/// an existing async context (use [`download_file()`] instead).
557///
558/// # Errors
559///
560/// Same as [`download_file()`].
561pub fn download_file_blocking(
562 repo_id: String,
563 filename: &str,
564 config: &FetchConfig,
565) -> Result<DownloadOutcome<PathBuf>, FetchError> {
566 let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
567 path: PathBuf::from("<runtime>"),
568 source: e,
569 })?;
570 rt.block_on(download_file(repo_id, filename, config))
571}
572
573/// Blocking version of [`download_files_with_config()`] for non-async callers.
574///
575/// Creates a Tokio runtime internally. Do not call from within
576/// an existing async context (use [`download_files_with_config()`] instead).
577///
578/// # Errors
579///
580/// Same as [`download_files_with_config()`].
581pub fn download_files_with_config_blocking(
582 repo_id: String,
583 config: &FetchConfig,
584) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
585 let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
586 path: PathBuf::from("<runtime>"),
587 source: e,
588 })?;
589 rt.block_on(download_files_with_config(repo_id, config))
590}
591
592/// Downloads files according to an existing [`DownloadPlan`].
593///
594/// Only uncached files in the plan are downloaded. The `config` controls
595/// authentication, progress, timeouts, and performance settings.
596/// Use [`DownloadPlan::recommended_config()`] to compute an optimized config,
597/// or override specific fields via [`DownloadPlan::recommended_config_builder()`].
598///
599/// # Errors
600///
601/// Returns [`FetchError::Io`] if the cache directory cannot be resolved.
602/// Same error conditions as [`download_with_config()`] for the download itself.
603pub async fn download_with_plan(
604 plan: &DownloadPlan,
605 config: &FetchConfig,
606) -> Result<DownloadOutcome<PathBuf>, FetchError> {
607 if plan.fully_cached() {
608 // Resolve snapshot path from cache and return immediately.
609 let cache_dir = config
610 .output_dir
611 .clone()
612 .map_or_else(cache::hf_cache_dir, Ok)?;
613 let repo_dir = cache_layout::repo_dir(&cache_dir, plan.repo_id.as_str());
614 let snapshot_dir = cache_layout::snapshot_dir(&repo_dir, plan.revision.as_str());
615 return Ok(DownloadOutcome::Cached(snapshot_dir));
616 }
617
618 // Delegate to the standard download path which will re-check cache
619 // internally. The plan's value is the dry-run preview and the
620 // recommended config computed by the caller.
621 // BORROW: explicit .clone() for owned String argument
622 download_with_config(plan.repo_id.clone(), config).await
623}
624
625/// Blocking version of [`download_with_plan()`] for non-async callers.
626///
627/// Creates a Tokio runtime internally. Do not call from within
628/// an existing async context (use [`download_with_plan()`] instead).
629///
630/// # Errors
631///
632/// Same as [`download_with_plan()`].
633pub fn download_with_plan_blocking(
634 plan: &DownloadPlan,
635 config: &FetchConfig,
636) -> Result<DownloadOutcome<PathBuf>, FetchError> {
637 let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
638 path: PathBuf::from("<runtime>"),
639 source: e,
640 })?;
641 rt.block_on(download_with_plan(plan, config))
642}