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//! ## `HuggingFace` Cache
125//!
126//! Downloaded files are stored in the standard `HuggingFace` cache directory
127//! (`~/.cache/huggingface/hub/`), ensuring compatibility with Python tooling.
128//!
129//! ## Cache Management
130//!
131//! v0.10.0 adds library APIs for inspecting, verifying, and pruning the local
132//! cache. [`cache::cache_summary`] enumerates every cached repo with size and
133//! file counts; [`cache::repo_status`] gives a per-file `Complete` / `Partial` /
134//! `Missing` breakdown for one repo; [`cache::verify_cache`] re-checks `SHA256`
135//! digests of cached files against `HuggingFace` LFS metadata; and
136//! [`cache::find_partial_files`] locates `.chunked.part` orphans from
137//! interrupted downloads.
138//!
139//! For long verifications (multi-GiB safetensors files), drive
140//! [`cache::verify_cache_with_progress`] with an [`Fn`] callback that receives
141//! [`cache::VerifyEvent`]s so a CLI or GUI can render a spinner or progress
142//! bar without polling.
143//!
144//! ```rust,no_run
145//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
146//! use hf_fetch_model::cache::{self, VerifyStatus};
147//!
148//! let results = cache::verify_cache("google/gemma-2-2b-it", None, None).await?;
149//! let ok = results
150//! .iter()
151//! .filter(|r| matches!(r.status, VerifyStatus::Ok))
152//! .count();
153//! let mismatch = results
154//! .iter()
155//! .filter(|r| matches!(r.status, VerifyStatus::Mismatch { .. }))
156//! .count();
157//! println!("{}/{} files verified, {} mismatches", ok, results.len(), mismatch);
158//! # Ok(())
159//! # }
160//! ```
161//!
162//! ## Download Durability
163//!
164//! Multi-connection downloads survive interruption. When a download is
165//! aborted by [`FetchConfigBuilder::timeout_per_file`] (default 300 s),
166//! Ctrl-C, panic, or a transient chunk error, the partial `.chunked.part`
167//! file plus a small per-chunk progress sidecar are kept on disk. The next
168//! call to [`download_with_config`] for the same file picks up where it
169//! stopped — each parallel chunk sends a fresh `Range` request that skips
170//! the bytes it already has — provided the upstream etag still matches.
171//! On etag change, schema-version mismatch, or a different
172//! [`FetchConfigBuilder::connections_per_file`] count, the partial is
173//! discarded and a fresh download starts.
174//!
175//! For slow connections on multi-GiB files, raise the per-file budget to
176//! match real throughput:
177//!
178//! ```rust,no_run
179//! # async fn example() -> Result<(), hf_fetch_model::FetchError> {
180//! use std::time::Duration;
181//! use hf_fetch_model::FetchConfig;
182//!
183//! let config = FetchConfig::builder()
184//! .timeout_per_file(Duration::from_secs(1800))
185//! .build()?;
186//! # let _ = hf_fetch_model::download_with_config(
187//! # "google/gemma-4-E2B-it".to_owned(),
188//! # &config,
189//! # ).await?;
190//! # Ok(())
191//! # }
192//! ```
193//!
194//! ## Authentication
195//!
196//! Set the `HF_TOKEN` environment variable to access private or gated models,
197//! or use [`FetchConfig::builder().token()`](FetchConfigBuilder::token).
198
199pub mod cache;
200pub mod cache_layout;
201pub mod checksum;
202mod chunked;
203mod chunked_state;
204pub mod config;
205pub mod discover;
206pub mod download;
207pub mod error;
208pub mod inspect;
209pub mod plan;
210pub mod progress;
211pub mod repo;
212mod retry;
213
214pub use chunked::build_client;
215pub use config::{
216 compile_glob_patterns, file_matches, has_glob_chars, FetchConfig, FetchConfigBuilder, Filter,
217};
218pub use discover::{DiscoveredFamily, GateStatus, ModelCardMetadata, SearchResult};
219pub use download::DownloadOutcome;
220pub use error::{FetchError, FileFailure};
221pub use inspect::{AdapterConfig, ModelConfig};
222pub use plan::{download_plan, DownloadPlan, FilePlan};
223pub use progress::{ProgressEvent, ProgressReceiver};
224
225use std::collections::HashMap;
226use std::path::PathBuf;
227
228use hf_hub::{Repo, RepoType};
229
230/// Pre-flight check for gated model access.
231///
232/// Two cases:
233/// - **No token**: checks the model metadata (unauthenticated) for gating
234/// status and rejects with a clear message if gated.
235/// - **Token present**: if the model is gated, makes one authenticated
236/// metadata request to verify the token actually grants access. Catches
237/// invalid tokens and unaccepted licenses before the download starts.
238///
239/// If the metadata request itself fails (network error, private repo),
240/// the check is silently skipped so that normal download error handling
241/// can take over.
242async fn preflight_gated_check(repo_id: &str, config: &FetchConfig) -> Result<(), FetchError> {
243 // Best-effort: if the metadata call fails, let the download proceed.
244 let Ok(metadata) = discover::fetch_model_card(repo_id).await else {
245 return Ok(());
246 };
247
248 if !metadata.gated.is_gated() {
249 return Ok(());
250 }
251
252 // Model is gated — check auth.
253 if config.token.is_none() {
254 return Err(FetchError::Auth {
255 reason: format!(
256 "{repo_id} is a gated model — accept the license at \
257 https://huggingface.co/{repo_id} and set HF_TOKEN or pass --token"
258 ),
259 });
260 }
261
262 // Token is present — verify it grants access with a lightweight probe.
263 let probe_client = chunked::build_client(config.token.as_deref())?;
264 let probe = repo::list_repo_files_with_metadata(
265 repo_id,
266 config.token.as_deref(),
267 config.revision.as_deref(),
268 &probe_client,
269 )
270 .await;
271
272 if let Err(ref e) = probe {
273 // BORROW: explicit .to_string() for error Display formatting
274 let msg = e.to_string();
275 if msg.contains("401") || msg.contains("403") {
276 return Err(FetchError::Auth {
277 reason: format!(
278 "{repo_id} is a gated model and your token was rejected — \
279 accept the license at https://huggingface.co/{repo_id} \
280 and check that your token is valid"
281 ),
282 });
283 }
284 }
285
286 Ok(())
287}
288
289/// Downloads all files from a `HuggingFace` model repository.
290///
291/// Uses high-throughput mode for maximum download speed, including
292/// auto-tuned concurrency, chunked multi-connection downloads for large
293/// files, and plan-optimized settings based on file size distribution.
294/// Files are stored in the standard `HuggingFace` cache layout
295/// (`~/.cache/huggingface/hub/`).
296///
297/// Authentication is handled via the `HF_TOKEN` environment variable when set.
298///
299/// For filtering, progress, and other options, use [`download_with_config()`].
300///
301/// # Arguments
302///
303/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
304///
305/// # Returns
306///
307/// The path to the snapshot directory containing all downloaded files.
308///
309/// # Errors
310///
311/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
312/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
313/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
314/// * [`FetchError::InvalidPattern`] — if the default config fails to build (should not happen).
315pub async fn download(repo_id: String) -> Result<DownloadOutcome<PathBuf>, FetchError> {
316 let config = FetchConfig::builder().build()?;
317 download_with_config(repo_id, &config).await
318}
319
320/// Downloads files from a `HuggingFace` model repository using the given configuration.
321///
322/// Supports filtering, progress reporting, custom revision, authentication,
323/// and concurrency settings via [`FetchConfig`].
324///
325/// # Arguments
326///
327/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
328/// * `config` — Download configuration (see [`FetchConfig::builder()`]).
329///
330/// # Returns
331///
332/// The path to the snapshot directory containing all downloaded files.
333///
334/// # Errors
335///
336/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
337/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
338/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
339pub async fn download_with_config(
340 repo_id: String,
341 config: &FetchConfig,
342) -> Result<DownloadOutcome<PathBuf>, FetchError> {
343 // BORROW: explicit .as_str() instead of Deref coercion
344 preflight_gated_check(repo_id.as_str(), config).await?;
345
346 let mut builder = hf_hub::api::tokio::ApiBuilder::new().high();
347
348 if let Some(ref token) = config.token {
349 // BORROW: explicit .clone() to pass owned String
350 builder = builder.with_token(Some(token.clone()));
351 }
352
353 if let Some(ref dir) = config.output_dir {
354 // BORROW: explicit .clone() for owned PathBuf
355 builder = builder.with_cache_dir(dir.clone());
356 }
357
358 let api = builder.build().map_err(FetchError::Api)?;
359
360 let hf_repo = match config.revision {
361 Some(ref rev) => {
362 // BORROW: explicit .clone() for owned String arguments
363 Repo::with_revision(repo_id.clone(), RepoType::Model, rev.clone())
364 }
365 None => Repo::new(repo_id.clone(), RepoType::Model),
366 };
367
368 let repo = api.repo(hf_repo);
369 download::download_all_files(repo, repo_id, Some(config)).await
370}
371
372/// Blocking version of [`download()`] for non-async callers.
373///
374/// Creates a Tokio runtime internally. Do not call from within
375/// an existing async context (use [`download()`] instead).
376///
377/// # Errors
378///
379/// Same as [`download()`].
380pub fn download_blocking(repo_id: String) -> Result<DownloadOutcome<PathBuf>, FetchError> {
381 let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
382 path: PathBuf::from("<runtime>"),
383 source: e,
384 })?;
385 rt.block_on(download(repo_id))
386}
387
388/// Blocking version of [`download_with_config()`] for non-async callers.
389///
390/// Creates a Tokio runtime internally. Do not call from within
391/// an existing async context (use [`download_with_config()`] instead).
392///
393/// # Errors
394///
395/// Same as [`download_with_config()`].
396pub fn download_with_config_blocking(
397 repo_id: String,
398 config: &FetchConfig,
399) -> Result<DownloadOutcome<PathBuf>, FetchError> {
400 let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
401 path: PathBuf::from("<runtime>"),
402 source: e,
403 })?;
404 rt.block_on(download_with_config(repo_id, config))
405}
406
407/// Downloads all files from a `HuggingFace` model repository and returns
408/// a filename → path map.
409///
410/// Each key is the relative filename within the repository (e.g.,
411/// `"config.json"`, `"model.safetensors"`), and each value is the
412/// absolute local path to the downloaded file.
413///
414/// Uses the same high-throughput defaults as [`download()`]: auto-tuned
415/// concurrency and chunked multi-connection downloads for large files.
416///
417/// For filtering, progress, and other options, use
418/// [`download_files_with_config()`].
419///
420/// # Arguments
421///
422/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
423///
424/// # Errors
425///
426/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
427/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
428/// * [`FetchError::InvalidPattern`] — if the default config fails to build (should not happen).
429pub async fn download_files(
430 repo_id: String,
431) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
432 let config = FetchConfig::builder().build()?;
433 download_files_with_config(repo_id, &config).await
434}
435
436/// Downloads files from a `HuggingFace` model repository using the given
437/// configuration and returns a filename → path map.
438///
439/// Each key is the relative filename within the repository (e.g.,
440/// `"config.json"`, `"model.safetensors"`), and each value is the
441/// absolute local path to the downloaded file.
442///
443/// # Arguments
444///
445/// * `repo_id` — The repository identifier (e.g., `"google/gemma-2-2b-it"`).
446/// * `config` — Download configuration (see [`FetchConfig::builder()`]).
447///
448/// # Errors
449///
450/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
451/// * [`FetchError::Api`] — if the `HuggingFace` API or download fails (includes auth failures).
452/// * [`FetchError::RepoNotFound`] — if the repository does not exist.
453pub async fn download_files_with_config(
454 repo_id: String,
455 config: &FetchConfig,
456) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
457 // BORROW: explicit .as_str() instead of Deref coercion
458 preflight_gated_check(repo_id.as_str(), config).await?;
459
460 let mut builder = hf_hub::api::tokio::ApiBuilder::new().high();
461
462 if let Some(ref token) = config.token {
463 // BORROW: explicit .clone() to pass owned String
464 builder = builder.with_token(Some(token.clone()));
465 }
466
467 if let Some(ref dir) = config.output_dir {
468 // BORROW: explicit .clone() for owned PathBuf
469 builder = builder.with_cache_dir(dir.clone());
470 }
471
472 let api = builder.build().map_err(FetchError::Api)?;
473
474 let hf_repo = match config.revision {
475 Some(ref rev) => {
476 // BORROW: explicit .clone() for owned String arguments
477 Repo::with_revision(repo_id.clone(), RepoType::Model, rev.clone())
478 }
479 None => Repo::new(repo_id.clone(), RepoType::Model),
480 };
481
482 let repo = api.repo(hf_repo);
483 download::download_all_files_map(repo, repo_id, Some(config)).await
484}
485
486/// Blocking version of [`download_files()`] for non-async callers.
487///
488/// Creates a Tokio runtime internally. Do not call from within
489/// an existing async context (use [`download_files()`] instead).
490///
491/// # Errors
492///
493/// Same as [`download_files()`].
494pub fn download_files_blocking(
495 repo_id: String,
496) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
497 let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
498 path: PathBuf::from("<runtime>"),
499 source: e,
500 })?;
501 rt.block_on(download_files(repo_id))
502}
503
504/// Downloads a single file from a `HuggingFace` model repository.
505///
506/// Returns the local cache path. If the file is already cached (and
507/// checksums match when `verify_checksums` is enabled), the download
508/// is skipped and the cached path is returned immediately.
509///
510/// Files at or above [`FetchConfig`]'s `chunk_threshold` (auto-tuned by
511/// the download plan optimizer, or 100 MiB fallback) are downloaded using
512/// multiple parallel HTTP Range connections (`connections_per_file`,
513/// auto-tuned or 8 fallback). Smaller files use a single connection.
514///
515/// # Arguments
516///
517/// * `repo_id` — Repository identifier (e.g., `"mntss/clt-gemma-2-2b-426k"`).
518/// * `filename` — Exact filename within the repository (e.g., `"W_enc_5.safetensors"`).
519/// * `config` — Shared configuration for auth, progress, checksums, retries, and chunking.
520///
521/// # Errors
522///
523/// * [`FetchError::Auth`] — if the repository is gated and access is denied (no token, invalid token, or license not accepted).
524/// * [`FetchError::Http`] — if the file does not exist in the repository.
525/// * [`FetchError::Api`] — on download failure (after retries).
526/// * [`FetchError::Checksum`] — if verification is enabled and fails.
527pub async fn download_file(
528 repo_id: String,
529 filename: &str,
530 config: &FetchConfig,
531) -> Result<DownloadOutcome<PathBuf>, FetchError> {
532 // BORROW: explicit .as_str() instead of Deref coercion
533 preflight_gated_check(repo_id.as_str(), config).await?;
534
535 let mut builder = hf_hub::api::tokio::ApiBuilder::new().high();
536
537 if let Some(ref token) = config.token {
538 // BORROW: explicit .clone() to pass owned String
539 builder = builder.with_token(Some(token.clone()));
540 }
541
542 if let Some(ref dir) = config.output_dir {
543 // BORROW: explicit .clone() for owned PathBuf
544 builder = builder.with_cache_dir(dir.clone());
545 }
546
547 let api = builder.build().map_err(FetchError::Api)?;
548
549 let hf_repo = match config.revision {
550 Some(ref rev) => {
551 // BORROW: explicit .clone() for owned String arguments
552 Repo::with_revision(repo_id.clone(), RepoType::Model, rev.clone())
553 }
554 None => Repo::new(repo_id.clone(), RepoType::Model),
555 };
556
557 let repo = api.repo(hf_repo);
558 download::download_file_by_name(repo, repo_id, filename, config).await
559}
560
561/// Blocking version of [`download_file()`] for non-async callers.
562///
563/// Creates a Tokio runtime internally. Do not call from within
564/// an existing async context (use [`download_file()`] instead).
565///
566/// # Errors
567///
568/// Same as [`download_file()`].
569pub fn download_file_blocking(
570 repo_id: String,
571 filename: &str,
572 config: &FetchConfig,
573) -> Result<DownloadOutcome<PathBuf>, FetchError> {
574 let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
575 path: PathBuf::from("<runtime>"),
576 source: e,
577 })?;
578 rt.block_on(download_file(repo_id, filename, config))
579}
580
581/// Blocking version of [`download_files_with_config()`] for non-async callers.
582///
583/// Creates a Tokio runtime internally. Do not call from within
584/// an existing async context (use [`download_files_with_config()`] instead).
585///
586/// # Errors
587///
588/// Same as [`download_files_with_config()`].
589pub fn download_files_with_config_blocking(
590 repo_id: String,
591 config: &FetchConfig,
592) -> Result<DownloadOutcome<HashMap<String, PathBuf>>, FetchError> {
593 let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
594 path: PathBuf::from("<runtime>"),
595 source: e,
596 })?;
597 rt.block_on(download_files_with_config(repo_id, config))
598}
599
600/// Downloads files according to an existing [`DownloadPlan`].
601///
602/// Only uncached files in the plan are downloaded. The `config` controls
603/// authentication, progress, timeouts, and performance settings.
604/// Use [`DownloadPlan::recommended_config()`] to compute an optimized config,
605/// or override specific fields via [`DownloadPlan::recommended_config_builder()`].
606///
607/// # Errors
608///
609/// Returns [`FetchError::Io`] if the cache directory cannot be resolved.
610/// Same error conditions as [`download_with_config()`] for the download itself.
611pub async fn download_with_plan(
612 plan: &DownloadPlan,
613 config: &FetchConfig,
614) -> Result<DownloadOutcome<PathBuf>, FetchError> {
615 if plan.fully_cached() {
616 // Resolve snapshot path from cache and return immediately.
617 let cache_dir = config
618 .output_dir
619 .clone()
620 .map_or_else(cache::hf_cache_dir, Ok)?;
621 let repo_dir = cache_layout::repo_dir(&cache_dir, plan.repo_id.as_str());
622 let snapshot_dir = cache_layout::snapshot_dir(&repo_dir, plan.revision.as_str());
623 return Ok(DownloadOutcome::Cached(snapshot_dir));
624 }
625
626 // Delegate to the standard download path which will re-check cache
627 // internally. The plan's value is the dry-run preview and the
628 // recommended config computed by the caller.
629 // BORROW: explicit .clone() for owned String argument
630 download_with_config(plan.repo_id.clone(), config).await
631}
632
633/// Blocking version of [`download_with_plan()`] for non-async callers.
634///
635/// Creates a Tokio runtime internally. Do not call from within
636/// an existing async context (use [`download_with_plan()`] instead).
637///
638/// # Errors
639///
640/// Same as [`download_with_plan()`].
641pub fn download_with_plan_blocking(
642 plan: &DownloadPlan,
643 config: &FetchConfig,
644) -> Result<DownloadOutcome<PathBuf>, FetchError> {
645 let rt = tokio::runtime::Runtime::new().map_err(|e| FetchError::Io {
646 path: PathBuf::from("<runtime>"),
647 source: e,
648 })?;
649 rt.block_on(download_with_plan(plan, config))
650}