Skip to main content

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