Skip to main content

hf_fetch_model/
repo.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Repository file listing via the `HuggingFace` API.
4//!
5//! This module provides functions to list all files in a `HuggingFace` model
6//! repository, using the `hf-hub` crate's `info()` API and optionally
7//! fetching extended metadata (sizes and SHA256 hashes) via a direct HTTP call.
8
9use hf_hub::api::tokio::ApiRepo;
10use serde::Deserialize;
11
12use crate::error::FetchError;
13
14/// A file entry in a `HuggingFace` repository.
15#[derive(Debug, Clone)]
16pub struct RepoFile {
17    /// The relative path of the file within the repository.
18    pub filename: String,
19    /// File size in bytes (if known from API metadata).
20    pub size: Option<u64>,
21    /// SHA256 hex digest (if the file is stored in LFS).
22    pub sha256: Option<String>,
23}
24
25/// Lists all files in the given repository.
26///
27/// # Errors
28///
29/// Returns [`FetchError::Api`] if the `HuggingFace` API request fails.
30/// Returns [`FetchError::RepoNotFound`] if the repository does not exist.
31pub async fn list_repo_files(repo: &ApiRepo, repo_id: String) -> Result<Vec<RepoFile>, FetchError> {
32    let info = repo.info().await.map_err(|e| {
33        // BORROW: explicit .to_string() for error message inspection
34        let msg = e.to_string();
35        if msg.contains("404") {
36            FetchError::RepoNotFound { repo_id }
37        } else {
38            FetchError::Api(e)
39        }
40    })?;
41
42    let files = info
43        .siblings
44        .into_iter()
45        .map(|s| RepoFile {
46            filename: s.rfilename,
47            size: None,
48            sha256: None,
49        })
50        .collect();
51
52    Ok(files)
53}
54
55// --- Direct HF API metadata (for SHA256 and file sizes) ---
56
57/// Raw JSON sibling entry from the `HuggingFace` API.
58#[derive(Debug, Deserialize)]
59struct ApiSibling {
60    rfilename: String,
61    #[serde(default)]
62    size: Option<u64>,
63    #[serde(default)]
64    lfs: Option<ApiLfs>,
65}
66
67/// LFS metadata attached to a sibling entry.
68#[derive(Debug, Deserialize)]
69struct ApiLfs {
70    sha256: String,
71    size: u64,
72}
73
74/// Raw JSON response from `GET /api/models/{repo_id}`.
75#[derive(Debug, Deserialize)]
76struct ApiModelInfo {
77    siblings: Vec<ApiSibling>,
78    /// Commit SHA of the resolved revision, when present in the API response.
79    #[serde(default)]
80    sha: Option<String>,
81}
82
83/// Fetches extended file metadata (sizes and SHA256 hashes) via the `HuggingFace` REST API.
84///
85/// This makes a direct HTTP call to `https://huggingface.co/api/models/{repo_id}?blobs=true`
86/// to retrieve file sizes and LFS metadata that `hf-hub`'s `info()` does not expose.
87///
88/// Accepts a shared `reqwest::Client` to reuse TCP connections and TLS sessions
89/// across calls. Use [`chunked::build_client`](crate::chunked::build_client) to
90/// create a client with the standard connect timeout and auth headers.
91///
92/// # Errors
93///
94/// Returns [`FetchError::Http`] if the HTTP request fails.
95/// Returns [`FetchError::RepoNotFound`] if the repository does not exist.
96pub async fn list_repo_files_with_metadata(
97    repo_id: &str,
98    token: Option<&str>,
99    revision: Option<&str>,
100    client: &reqwest::Client,
101) -> Result<Vec<RepoFile>, FetchError> {
102    let (files, _commit) = list_repo_files_with_commit(repo_id, token, revision, client).await?;
103    Ok(files)
104}
105
106/// Fetches file metadata **and** the resolved commit SHA of the revision.
107///
108/// Same HTTP call as [`list_repo_files_with_metadata`], but also returns the
109/// `sha` field from the `HuggingFace` API response. Callers that need to show
110/// or pin the current revision (e.g. `inspect --list`) use this variant; all
111/// other callers should prefer [`list_repo_files_with_metadata`].
112///
113/// The commit SHA is `Option<String>` because the API has not always returned
114/// it on every endpoint variant; treat it as informational.
115///
116/// # Errors
117///
118/// Returns [`FetchError::Http`] if the HTTP request fails.
119/// Returns [`FetchError::RepoNotFound`] if the repository does not exist.
120pub async fn list_repo_files_with_commit(
121    repo_id: &str,
122    token: Option<&str>,
123    revision: Option<&str>,
124    client: &reqwest::Client,
125) -> Result<(Vec<RepoFile>, Option<String>), FetchError> {
126    let mut url = format!("https://huggingface.co/api/models/{repo_id}?blobs=true");
127    if let Some(rev) = revision {
128        url = format!("{url}&revision={rev}");
129    }
130
131    // BORROW: explicit .as_str() instead of Deref coercion
132    let mut request = client.get(url.as_str());
133    if let Some(t) = token {
134        request = request.bearer_auth(t);
135    }
136
137    let response = request
138        .send()
139        .await
140        .map_err(|e| FetchError::Http(e.to_string()))?;
141
142    if response.status() == reqwest::StatusCode::NOT_FOUND {
143        // BORROW: explicit .to_owned() for &str → owned String field
144        return Err(FetchError::RepoNotFound {
145            repo_id: repo_id.to_owned(),
146        });
147    }
148
149    if !response.status().is_success() {
150        return Err(FetchError::Http(format!(
151            "HF API returned status {}",
152            response.status()
153        )));
154    }
155
156    let info: ApiModelInfo = response
157        .json()
158        .await
159        .map_err(|e| FetchError::Http(e.to_string()))?;
160
161    let commit_sha = info.sha;
162    let files = info
163        .siblings
164        .into_iter()
165        .map(|s| {
166            let (size, sha256) = match s.lfs {
167                Some(lfs) => (Some(lfs.size), Some(lfs.sha256)),
168                None => (s.size, None),
169            };
170            RepoFile {
171                filename: s.rfilename,
172                size,
173                sha256,
174            }
175        })
176        .collect();
177
178    Ok((files, commit_sha))
179}