hf_fetch_model/cache.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! `HuggingFace` cache directory resolution, model family scanning, disk usage,
4//! and integrity verification.
5//!
6//! [`hf_cache_dir()`] locates the local HF cache. [`list_cached_families()`]
7//! scans downloaded models and groups them by `model_type`.
8//! [`cache_summary()`] provides per-repo size totals,
9//! [`cache_repo_usage()`] returns per-file disk usage for a single repo, and
10//! [`verify_cache()`] re-checks `SHA256` digests of cached files against
11//! `HuggingFace` LFS metadata.
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17
18use crate::error::FetchError;
19
20/// Filename of the per-repo `hf-fm` snapshot sidecar.
21///
22/// Lives at `{cache_root}/models--{org}--{name}/.hf-fm-snapshot.json`.
23/// Written by `download` (recording the active `--preset` / `--filter` /
24/// `--exclude`) and consumed by `status` (to distinguish files deliberately
25/// skipped via the preset's glob list from files that are genuinely missing).
26pub const SNAPSHOT_FILENAME: &str = ".hf-fm-snapshot.json";
27
28/// Schema version of the on-disk [`Snapshot`] file. Bumped on incompatible changes.
29pub const SNAPSHOT_VERSION: u32 = 1;
30
31/// On-disk record of the arguments that produced a cached repository.
32///
33/// Persisted by `hf-fm download` as a small JSON file at the repository's
34/// cache root, alongside `refs/`, `blobs/`, and `snapshots/`. Read back by
35/// `hf-fm status` so files that don't match the recorded preset can be
36/// reported as [`FileStatus::Excluded`] instead of [`FileStatus::Missing`].
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct Snapshot {
39 /// Schema version. Equals [`SNAPSHOT_VERSION`] for newly-written files.
40 pub version: u32,
41 /// Git revision at download time (resolved commit SHA or branch name).
42 pub revision: String,
43 /// The `--preset` value used at download time, if any. One of
44 /// `"safetensors"`, `"gguf"`, `"npz"`, `"pth"`, `"config-only"`.
45 pub preset: Option<String>,
46 /// `--filter` glob patterns used at download time. Reserved for a later
47 /// patch that adds `status --filter`; not yet consumed by `status`.
48 pub filter: Vec<String>,
49 /// `--exclude` glob patterns used at download time. Reserved for a later
50 /// patch that adds `status --exclude`; not yet consumed by `status`.
51 pub exclude: Vec<String>,
52}
53
54/// Returns the absolute path of the [`SNAPSHOT_FILENAME`] sidecar for a given
55/// repository cache directory.
56#[must_use]
57pub fn snapshot_path(repo_dir: &Path) -> PathBuf {
58 repo_dir.join(SNAPSHOT_FILENAME)
59}
60
61/// Reads the per-repo [`Snapshot`] sidecar if it exists.
62///
63/// A missing sidecar is not an error — older caches (downloaded before this
64/// feature) simply return `Ok(None)`.
65///
66/// # Errors
67///
68/// Returns [`FetchError::Io`] if the file exists but
69/// cannot be read.
70/// Returns [`FetchError::InvalidArgument`]
71/// if the file is present but its JSON cannot be parsed (e.g. corruption or
72/// a future schema version this binary cannot understand).
73pub fn read_snapshot(repo_dir: &Path) -> Result<Option<Snapshot>, FetchError> {
74 let path = snapshot_path(repo_dir);
75 let bytes = match std::fs::read(&path) {
76 Ok(b) => b,
77 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
78 Err(e) => return Err(FetchError::Io { path, source: e }),
79 };
80 let snapshot: Snapshot = serde_json::from_slice(&bytes).map_err(|e| {
81 FetchError::InvalidArgument(format!("failed to parse snapshot {}: {e}", path.display()))
82 })?;
83 Ok(Some(snapshot))
84}
85
86/// Writes the [`Snapshot`] sidecar for a repository, atomically (write to a
87/// `.tmp` sibling, then rename).
88///
89/// Overwrites any previously-written sidecar — the design is intentionally
90/// last-download-wins.
91///
92/// # Errors
93///
94/// Returns [`FetchError::Io`] on any filesystem
95/// failure (parent directory missing, no write permission, rename across
96/// filesystems, etc.).
97pub fn write_snapshot(repo_dir: &Path, snapshot: &Snapshot) -> Result<(), FetchError> {
98 let path = snapshot_path(repo_dir);
99 let tmp = path.with_extension("json.tmp");
100 let bytes = serde_json::to_vec_pretty(snapshot)
101 .map_err(|e| FetchError::InvalidArgument(format!("failed to serialize snapshot: {e}")))?;
102 std::fs::write(&tmp, bytes).map_err(|e| FetchError::Io {
103 path: tmp.clone(),
104 source: e,
105 })?;
106 std::fs::rename(&tmp, &path).map_err(|e| FetchError::Io {
107 path: path.clone(),
108 source: e,
109 })?;
110 Ok(())
111}
112
113/// Reconstructs a repo ID from a `models--org--name` directory name.
114///
115/// Returns `None` if the directory name does not start with `models--`.
116fn repo_id_from_folder_name(dir_name: &str) -> Option<String> {
117 let repo_part = dir_name.strip_prefix("models--")?;
118
119 // Reconstruct repo_id: replace first "--" with "/".
120 let repo_id = match repo_part.find("--") {
121 Some(pos) => {
122 let (org, name_with_sep) = repo_part.split_at(pos);
123 let name = name_with_sep.get(2..).unwrap_or_default();
124 format!("{org}/{name}")
125 }
126 None => repo_part.to_string(),
127 };
128
129 Some(repo_id)
130}
131
132/// Returns the `HuggingFace` Hub cache directory.
133///
134/// Resolution order:
135/// 1. `HF_HOME` environment variable + `/hub`
136/// 2. `~/.cache/huggingface/hub/` (via [`dirs::home_dir()`])
137///
138/// # Errors
139///
140/// Returns [`FetchError::Io`] if the home directory cannot be determined.
141pub fn hf_cache_dir() -> Result<PathBuf, FetchError> {
142 if let Ok(home) = std::env::var("HF_HOME") {
143 let mut path = PathBuf::from(home);
144 path.push("hub");
145 return Ok(path);
146 }
147
148 let home = dirs::home_dir().ok_or_else(|| FetchError::Io {
149 path: PathBuf::from("~"),
150 source: std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found"),
151 })?;
152
153 let mut path = home;
154 path.push(".cache");
155 path.push("huggingface");
156 path.push("hub");
157 Ok(path)
158}
159
160/// One repository inside a cached family, with optional quantization label.
161///
162/// Returned by [`list_cached_families`] so callers can render a quant column
163/// alongside the repo ID without re-reading every snapshot's `config.json`
164/// in a second pass.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct FamilyEntry {
167 /// The repository identifier (e.g., `"meta-llama/Llama-3.2-1B"`).
168 pub repo_id: String,
169 /// Quantization method as reported by `quantization_config.quant_method`
170 /// in the cached `config.json`. Falls back to `"gguf"` when any cached
171 /// file in the newest snapshot directory has a `.gguf` extension.
172 /// `None` for full-precision repos.
173 pub quant_method: Option<String>,
174}
175
176/// Scans the local HF cache for downloaded models and groups them by `model_type`.
177///
178/// Looks for `config.json` files inside model snapshot directories:
179/// `<cache>/models--<org>--<name>/snapshots/*/config.json`
180///
181/// Returns a map from `model_type` (e.g., `"llama"`) to a sorted list of
182/// [`FamilyEntry`] values, each pairing a repo ID with its quantization
183/// label (if any).
184///
185/// Models without a `model_type` field in their `config.json` are skipped.
186///
187/// # Errors
188///
189/// Returns [`FetchError::Io`] if the cache directory cannot be read.
190pub fn list_cached_families() -> Result<BTreeMap<String, Vec<FamilyEntry>>, FetchError> {
191 let cache_dir = hf_cache_dir()?;
192
193 if !cache_dir.exists() {
194 return Ok(BTreeMap::new());
195 }
196
197 let entries = std::fs::read_dir(&cache_dir).map_err(|e| FetchError::Io {
198 path: cache_dir.clone(),
199 source: e,
200 })?;
201
202 let mut families: BTreeMap<String, Vec<FamilyEntry>> = BTreeMap::new();
203
204 for entry in entries {
205 let Ok(entry) = entry else { continue };
206
207 let dir_name = entry.file_name();
208 // BORROW: explicit .to_string_lossy() for OsString → str conversion
209 let dir_str = dir_name.to_string_lossy();
210
211 let Some(repo_id) = repo_id_from_folder_name(&dir_str) else {
212 continue;
213 };
214
215 // Find the newest snapshot's config.json
216 let snapshots_dir = crate::cache_layout::snapshots_dir(&entry.path());
217 if !snapshots_dir.exists() {
218 continue;
219 }
220
221 if let Some((model_type, quant_method)) = find_family_info_in_snapshots(&snapshots_dir) {
222 families.entry(model_type).or_default().push(FamilyEntry {
223 repo_id,
224 quant_method,
225 });
226 }
227 }
228
229 // Sort repo lists within each family for stable output
230 for entries in families.values_mut() {
231 entries.sort_by(|a, b| a.repo_id.cmp(&b.repo_id));
232 }
233
234 Ok(families)
235}
236
237/// Searches snapshot directories for a `config.json` containing `model_type`,
238/// and reports an accompanying quantization label when one can be inferred.
239///
240/// Returns the first `(model_type, quant_method)` pair found. The quant
241/// label comes from `quantization_config.quant_method` in `config.json`,
242/// or falls back to `Some("gguf".to_owned())` when any sibling file in the
243/// same snapshot directory has a `.gguf` extension. Returns `None` if no
244/// snapshot yields a parseable `model_type`.
245fn find_family_info_in_snapshots(
246 snapshots_dir: &std::path::Path,
247) -> Option<(String, Option<String>)> {
248 let snapshots = std::fs::read_dir(snapshots_dir).ok()?;
249
250 for snap_entry in snapshots {
251 let Ok(snap_entry) = snap_entry else { continue };
252 let snap_path = snap_entry.path();
253 let config_path = snap_path.join("config.json");
254
255 if !config_path.exists() {
256 continue;
257 }
258
259 if let Some(model_type) = extract_model_type(&config_path) {
260 let quant_method = extract_quant_method(&config_path)
261 .or_else(|| snapshot_has_gguf(&snap_path).then(|| "gguf".to_owned())); // BORROW: explicit .to_owned()
262 return Some((model_type, quant_method));
263 }
264 }
265
266 None
267}
268
269/// Reads a `config.json` file and extracts the `model_type` field.
270fn extract_model_type(config_path: &std::path::Path) -> Option<String> {
271 let contents = std::fs::read_to_string(config_path).ok()?;
272 // BORROW: explicit .as_str() instead of Deref coercion
273 let value: serde_json::Value = serde_json::from_str(contents.as_str()).ok()?;
274 // BORROW: explicit .as_str() on serde_json Value
275 value.get("model_type")?.as_str().map(String::from)
276}
277
278/// Reads a `config.json` file and extracts the transformers-standard
279/// `quantization_config.quant_method` field, if present.
280///
281/// Returns `None` when the file is unreadable, malformed, or contains no
282/// quantization config (the repo is treated as full-precision in that case;
283/// the GGUF filename fallback is applied separately by the caller).
284fn extract_quant_method(config_path: &std::path::Path) -> Option<String> {
285 let contents = std::fs::read_to_string(config_path).ok()?;
286 // BORROW: explicit .as_str() instead of Deref coercion
287 let value: serde_json::Value = serde_json::from_str(contents.as_str()).ok()?;
288 // BORROW: explicit .as_str() on serde_json Value
289 value
290 .get("quantization_config")?
291 .get("quant_method")?
292 .as_str()
293 .map(String::from)
294}
295
296/// Returns `true` if any file in the snapshot directory has a `.gguf` extension.
297///
298/// Used as a fallback quant label when `config.json` carries no
299/// `quantization_config` (GGUF repos typically lack a transformers-style
300/// `config.json` `quantization_config` block). Extension comparison is
301/// case-insensitive via `OsStr::eq_ignore_ascii_case`.
302fn snapshot_has_gguf(snapshot_dir: &std::path::Path) -> bool {
303 let Ok(entries) = std::fs::read_dir(snapshot_dir) else {
304 return false;
305 };
306 for entry in entries.flatten() {
307 let path = entry.path();
308 if path
309 .extension()
310 .is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
311 {
312 return true;
313 }
314 }
315 false
316}
317
318/// Status of a single file in the cache.
319#[derive(Debug, Clone)]
320#[non_exhaustive]
321pub enum FileStatus {
322 /// File is fully downloaded (local size matches expected size, or no expected size known).
323 Complete {
324 /// Local file size in bytes.
325 local_size: u64,
326 },
327 /// File exists but is smaller than expected (interrupted download),
328 /// or a `.chunked.part` temp file was found in the blobs directory
329 /// (repo-level heuristic — may not correspond to this specific file).
330 Partial {
331 /// Local file size in bytes.
332 local_size: u64,
333 /// Expected file size in bytes.
334 expected_size: u64,
335 },
336 /// File is not present in the cache.
337 Missing {
338 /// Expected file size in bytes (0 if unknown).
339 expected_size: u64,
340 },
341 /// File is on the Hub but was deliberately not requested at download time
342 /// (or is now filtered out by `status --preset <P>`). Distinguished from
343 /// [`FileStatus::Missing`] so the user does not chase a "fix" for an
344 /// intentional skip.
345 Excluded {
346 /// Expected file size in bytes (0 if unknown).
347 expected_size: u64,
348 },
349}
350
351/// Cache status report for a repository.
352#[derive(Debug, Clone)]
353pub struct RepoStatus {
354 /// The repository identifier.
355 pub repo_id: String,
356 /// The resolved commit hash (if available).
357 pub commit_hash: Option<String>,
358 /// The cache directory for this repo.
359 pub cache_path: PathBuf,
360 /// Per-file status, sorted by filename.
361 pub files: Vec<(String, FileStatus)>,
362}
363
364impl RepoStatus {
365 /// Number of fully downloaded files.
366 #[must_use]
367 pub fn complete_count(&self) -> usize {
368 self.files
369 .iter()
370 .filter(|(_, s)| matches!(s, FileStatus::Complete { .. }))
371 .count()
372 }
373
374 /// Number of partially downloaded files.
375 #[must_use]
376 pub fn partial_count(&self) -> usize {
377 self.files
378 .iter()
379 .filter(|(_, s)| matches!(s, FileStatus::Partial { .. }))
380 .count()
381 }
382
383 /// Number of missing files.
384 #[must_use]
385 pub fn missing_count(&self) -> usize {
386 self.files
387 .iter()
388 .filter(|(_, s)| matches!(s, FileStatus::Missing { .. }))
389 .count()
390 }
391
392 /// Number of files deliberately excluded by the active preset / filter.
393 ///
394 /// Always `0` when `status` is invoked without a preset (whether from
395 /// CLI or sidecar) — the `Excluded` variant requires an active filter.
396 #[must_use]
397 pub fn excluded_count(&self) -> usize {
398 self.files
399 .iter()
400 .filter(|(_, s)| matches!(s, FileStatus::Excluded { .. }))
401 .count()
402 }
403}
404
405/// Inspects the local cache for a repository and compares against the remote file list.
406///
407/// When `preset_globs` is `Some`, files that do **not** match any of the
408/// supplied glob patterns and are absent locally are classified as
409/// [`FileStatus::Excluded`] instead of [`FileStatus::Missing`]. Files that
410/// **do** match the preset and are absent are still [`FileStatus::Missing`].
411/// Files that are present locally classify as `Complete` / `Partial`
412/// regardless of whether they match (the user has them — by what route is
413/// not status's concern).
414///
415/// # Arguments
416///
417/// * `repo_id` — The repository identifier (e.g., `"RWKV/RWKV7-Goose-World3-1.5B-HF"`).
418/// * `token` — Optional authentication token.
419/// * `revision` — Optional revision (defaults to `"main"`).
420/// * `preset_globs` — Optional include-glob list (typically returned by
421/// [`crate::config::preset_globs`]). When supplied, governs the
422/// `Excluded` distinction. When `None`, no `Excluded` entries are produced.
423///
424/// # Notes
425///
426/// Partial download detection is a repo-level heuristic: if any
427/// `.chunked.part` file exists in the repo's `blobs/` directory, all
428/// missing files are reported as [`FileStatus::Partial`] with the partial
429/// file's size. This may overcount partials when multiple files are
430/// missing but only one has an incomplete blob. Exact blob-to-file
431/// mapping would require LFS metadata.
432///
433/// # Errors
434///
435/// Returns [`FetchError::Http`] if the API request fails.
436/// Returns [`FetchError::Io`] if the cache directory cannot be read.
437/// Returns [`FetchError::InvalidPattern`]
438/// if any of the supplied `preset_globs` patterns fails to compile.
439pub async fn repo_status(
440 repo_id: &str,
441 token: Option<&str>,
442 revision: Option<&str>,
443 preset_globs: Option<&[&str]>,
444) -> Result<RepoStatus, FetchError> {
445 let revision = revision.unwrap_or("main");
446 let cache_dir = hf_cache_dir()?;
447 let repo_dir = crate::cache_layout::repo_dir(&cache_dir, repo_id);
448
449 // Read commit hash from refs file if available.
450 let commit_hash = read_ref(&repo_dir, revision);
451
452 // Fetch remote file list with sizes.
453 let client = crate::chunked::build_client(token)?;
454 let remote_files =
455 crate::repo::list_repo_files_with_metadata(repo_id, token, Some(revision), &client).await?;
456
457 // Determine snapshot directory.
458 // BORROW: explicit .as_deref() for Option<String> → Option<&str>
459 let snapshot_dir = commit_hash
460 .as_deref()
461 .map(|hash| crate::cache_layout::snapshot_dir(&repo_dir, hash));
462
463 // Pre-check for .chunked.part files in blobs directory (avoids re-scanning
464 // the blobs directory for every missing file in the loop below).
465 let blobs_dir = crate::cache_layout::blobs_dir(&repo_dir);
466 let has_any_partial = has_partial_blob(&blobs_dir);
467
468 // Compile preset globs once, outside the per-file loop. `compile_glob_patterns`
469 // already returns `Ok(None)` for empty slices, so an empty `Some(&[])`
470 // collapses to the "no filter" case below.
471 let preset_globset: Option<globset::GlobSet> = if let Some(patterns) = preset_globs {
472 // BORROW: explicit .to_string() — compile_glob_patterns expects &[String]
473 let owned: Vec<String> = patterns.iter().map(|s| (*s).to_string()).collect();
474 crate::compile_glob_patterns(&owned)?
475 } else {
476 None
477 };
478
479 // Cross-reference remote files against local state.
480 let mut files: Vec<(String, FileStatus)> = Vec::with_capacity(remote_files.len());
481
482 for remote in &remote_files {
483 let expected_size = remote.size.unwrap_or(0);
484
485 let local_path = snapshot_dir
486 .as_ref()
487 // BORROW: explicit .as_str() for path construction
488 .map(|dir| dir.join(remote.filename.as_str()));
489
490 let status = if let Some(ref path) = local_path {
491 if path.exists() {
492 let local_size = std::fs::metadata(path).map_or(0, |m| m.len());
493
494 if expected_size > 0 && local_size < expected_size {
495 FileStatus::Partial {
496 local_size,
497 expected_size,
498 }
499 } else {
500 FileStatus::Complete { local_size }
501 }
502 } else if has_any_partial {
503 // Blobs directory has .chunked.part temp files
504 let part_size = find_partial_blob_size(&blobs_dir);
505 FileStatus::Partial {
506 local_size: part_size,
507 expected_size,
508 }
509 } else if preset_globset
510 .as_ref()
511 // BORROW: explicit .as_str() — globset matches against &str
512 .is_some_and(|gs| !gs.is_match(remote.filename.as_str()))
513 {
514 // File is absent AND the active preset deliberately excludes it.
515 FileStatus::Excluded { expected_size }
516 } else {
517 FileStatus::Missing { expected_size }
518 }
519 } else {
520 FileStatus::Missing { expected_size }
521 };
522
523 // BORROW: explicit .clone() for owned String
524 files.push((remote.filename.clone(), status));
525 }
526
527 files.sort_by(|(a, _), (b, _)| a.cmp(b));
528
529 // BORROW: explicit .to_owned() for &str → owned String field
530 Ok(RepoStatus {
531 repo_id: repo_id.to_owned(),
532 commit_hash,
533 cache_path: repo_dir,
534 files,
535 })
536}
537
538/// Summary of a single cached model (local-only, no API calls).
539#[derive(Debug, Clone)]
540pub struct CachedModelSummary {
541 /// The repository identifier (e.g., `"RWKV/RWKV7-Goose-World3-1.5B-HF"`).
542 pub repo_id: String,
543 /// Number of files in the snapshot directory.
544 pub file_count: usize,
545 /// Total size on disk in bytes.
546 pub total_size: u64,
547 /// Whether there are incomplete `.chunked.part` temp files.
548 pub has_partial: bool,
549 /// Most recent modification time among files in the snapshot directory.
550 ///
551 /// `None` if no files were found or all metadata reads failed.
552 pub last_modified: Option<std::time::SystemTime>,
553}
554
555/// Scans the entire HF cache and returns a summary for each cached model.
556///
557/// This is a local-only operation (no API calls). It lists all `models--*`
558/// directories and counts files + sizes in each snapshot.
559///
560/// # Errors
561///
562/// Returns [`FetchError::Io`] if the cache directory cannot be read.
563pub fn cache_summary() -> Result<Vec<CachedModelSummary>, FetchError> {
564 let cache_dir = hf_cache_dir()?;
565
566 if !cache_dir.exists() {
567 return Ok(Vec::new());
568 }
569
570 let entries = std::fs::read_dir(&cache_dir).map_err(|e| FetchError::Io {
571 path: cache_dir.clone(),
572 source: e,
573 })?;
574
575 let mut summaries: Vec<CachedModelSummary> = Vec::new();
576
577 for entry in entries {
578 let Ok(entry) = entry else { continue };
579 let dir_name = entry.file_name();
580 // BORROW: explicit .to_string_lossy() for OsString → str conversion
581 let dir_str = dir_name.to_string_lossy();
582
583 let Some(repo_id) = repo_id_from_folder_name(&dir_str) else {
584 continue;
585 };
586
587 let repo_dir = entry.path();
588
589 // Count files and total size in snapshots.
590 let (file_count, total_size, last_modified) = count_snapshot_files(&repo_dir);
591
592 // Check for partial downloads.
593 let has_partial = find_partial_blob_size(&crate::cache_layout::blobs_dir(&repo_dir)) > 0;
594
595 summaries.push(CachedModelSummary {
596 repo_id,
597 file_count,
598 total_size,
599 has_partial,
600 last_modified,
601 });
602 }
603
604 summaries.sort_by(|a, b| a.repo_id.cmp(&b.repo_id));
605
606 Ok(summaries)
607}
608
609/// Returns the file count and total size for a single cached repo.
610///
611/// Avoids scanning the entire cache when only one repo's metrics are needed
612/// (e.g., for the `cache delete` preview).
613///
614/// # Errors
615///
616/// Returns [`FetchError::Io`] if the cache directory cannot be determined.
617pub fn repo_disk_usage(repo_id: &str) -> Result<(usize, u64), FetchError> {
618 let cache_dir = hf_cache_dir()?;
619 let repo_dir = crate::cache_layout::repo_dir(&cache_dir, repo_id);
620 let (file_count, total_size, _) = count_snapshot_files(&repo_dir);
621 Ok((file_count, total_size))
622}
623
624/// Checks whether a single cached repo has `.chunked.part` temp files.
625///
626/// Avoids scanning the entire cache when only one repo's partial status
627/// is needed (e.g., for the `du <REPO>` partial-download hint).
628///
629/// # Errors
630///
631/// Returns [`FetchError::Io`] if the cache directory cannot be determined.
632pub fn repo_has_partial(repo_id: &str) -> Result<bool, FetchError> {
633 let cache_dir = hf_cache_dir()?;
634 let repo_dir = crate::cache_layout::repo_dir(&cache_dir, repo_id);
635 let blobs_dir = crate::cache_layout::blobs_dir(&repo_dir);
636 Ok(find_partial_blob_size(&blobs_dir) > 0)
637}
638
639/// Counts files, total size, and most recent modification time across all
640/// snapshot directories for a repo.
641fn count_snapshot_files(repo_dir: &Path) -> (usize, u64, Option<std::time::SystemTime>) {
642 let snapshots_dir = crate::cache_layout::snapshots_dir(repo_dir);
643 let Ok(snapshots) = std::fs::read_dir(snapshots_dir) else {
644 return (0, 0, None);
645 };
646
647 let mut file_count: usize = 0;
648 let mut total_size: u64 = 0;
649 let mut latest: Option<std::time::SystemTime> = None;
650
651 for snap_entry in snapshots {
652 let Ok(snap_entry) = snap_entry else { continue };
653 let snap_path = snap_entry.path();
654 if !snap_path.is_dir() {
655 continue;
656 }
657 count_files_recursive(&snap_path, &mut file_count, &mut total_size, &mut latest);
658 }
659
660 (file_count, total_size, latest)
661}
662
663/// Recursively counts files, accumulates sizes, and tracks the most recent
664/// modification time in a directory.
665fn count_files_recursive(
666 dir: &Path,
667 count: &mut usize,
668 total: &mut u64,
669 latest: &mut Option<std::time::SystemTime>,
670) {
671 let Ok(entries) = std::fs::read_dir(dir) else {
672 return;
673 };
674
675 for entry in entries {
676 let Ok(entry) = entry else { continue };
677 let path = entry.path();
678 if path.is_dir() {
679 count_files_recursive(&path, count, total, latest);
680 } else if let Ok(meta) = entry.metadata() {
681 *count += 1;
682 *total += meta.len();
683 if let Ok(modified) = meta.modified() {
684 match *latest {
685 Some(current) if modified <= current => {} // EXPLICIT: current mtime is more recent, keep it
686 _ => *latest = Some(modified),
687 }
688 }
689 } else {
690 *count += 1;
691 }
692 }
693}
694
695/// Reads the commit hash from a refs file, if it exists.
696///
697/// Looks for `<repo_dir>/refs/<revision>` and returns the trimmed contents
698/// (a commit hash) or `None` if the file does not exist or is empty.
699#[must_use]
700pub fn read_ref(repo_dir: &Path, revision: &str) -> Option<String> {
701 let ref_path = crate::cache_layout::ref_path(repo_dir, revision);
702 std::fs::read_to_string(ref_path)
703 .ok()
704 // BORROW: explicit .to_owned() to convert trimmed &str → owned String
705 .map(|s| s.trim().to_owned())
706 .filter(|s| !s.is_empty())
707}
708
709/// Checks whether any `.chunked.part` temp file exists in the blobs directory.
710///
711/// This is a repo-level heuristic: it cannot map a specific filename to its
712/// blob without full LFS metadata, so it checks for any `.chunked.part` file.
713/// A `true` result means *some* file in the repo has a partial download.
714fn has_partial_blob(blobs_dir: &Path) -> bool {
715 find_partial_blob_size(blobs_dir) > 0
716}
717
718/// Returns the size of the first `.chunked.part` file found in the blobs directory.
719fn find_partial_blob_size(blobs_dir: &Path) -> u64 {
720 let Ok(entries) = std::fs::read_dir(blobs_dir) else {
721 return 0;
722 };
723
724 for entry in entries {
725 let Ok(entry) = entry else { continue };
726 let name = entry.file_name();
727 // BORROW: explicit .to_string_lossy() for OsString → str conversion
728 if name.to_string_lossy().ends_with(".chunked.part") {
729 return entry.metadata().map_or(0, |m| m.len());
730 }
731 }
732
733 0
734}
735
736/// A `.chunked.part` temp file left by an interrupted chunked download.
737#[derive(Debug, Clone)]
738pub struct PartialFile {
739 /// The repository identifier (e.g., `"meta-llama/Llama-3.2-1B"`).
740 pub repo_id: String,
741 /// The `.chunked.part` filename (e.g., `"abc123def456.chunked.part"`).
742 pub filename: String,
743 /// Absolute path to the `.chunked.part` file.
744 pub path: PathBuf,
745 /// Size of the partial file in bytes.
746 pub size: u64,
747}
748
749impl PartialFile {
750 /// Returns sibling sidecar paths that should be removed alongside this
751 /// partial: the resume-state sidecar `{etag}.chunked.part.state` and
752 /// any orphan write-tmp `{etag}.chunked.part.state.tmp` left by an
753 /// interrupted atomic save.
754 ///
755 /// The paths are returned even when the underlying files do not exist
756 /// — callers (`run_cache_clean_partial`) attempt removal best-effort.
757 #[must_use]
758 pub fn sidecar_paths(&self) -> Vec<PathBuf> {
759 let Some(parent) = self.path.parent() else {
760 return Vec::new();
761 };
762 // String concat (mirrors `cache_layout::temp_state_path`'s
763 // rationale): the etag may itself contain periods, so
764 // `Path::with_extension` would truncate at the wrong boundary.
765 // BORROW: explicit .clone() for owned String → mutated copy
766 let mut state_name = self.filename.clone();
767 state_name.push_str(".state");
768 // BORROW: explicit .clone() for owned String → mutated copy
769 let mut tmp_name = self.filename.clone();
770 tmp_name.push_str(".state.tmp");
771 vec![parent.join(state_name), parent.join(tmp_name)]
772 }
773}
774
775/// Finds all `.chunked.part` temp files in the `HuggingFace` cache.
776///
777/// Walks `models--*/blobs/` directories and collects partial files.
778/// When `repo_filter` is `Some`, only the matching repo is scanned.
779///
780/// Returns an empty `Vec` if the cache directory does not exist.
781///
782/// # Errors
783///
784/// Returns [`FetchError::Io`] if the cache directory cannot be read.
785pub fn find_partial_files(repo_filter: Option<&str>) -> Result<Vec<PartialFile>, FetchError> {
786 let cache_dir = hf_cache_dir()?;
787
788 if !cache_dir.exists() {
789 return Ok(Vec::new());
790 }
791
792 let entries = std::fs::read_dir(&cache_dir).map_err(|e| FetchError::Io {
793 // BORROW: explicit .clone() for owned PathBuf
794 path: cache_dir.clone(),
795 source: e,
796 })?;
797
798 let mut partials: Vec<PartialFile> = Vec::new();
799
800 for entry in entries {
801 let Ok(entry) = entry else { continue };
802 let dir_name = entry.file_name();
803 // BORROW: explicit .to_string_lossy() for OsString → str conversion
804 let dir_str = dir_name.to_string_lossy();
805
806 let Some(repo_id) = repo_id_from_folder_name(&dir_str) else {
807 continue;
808 };
809
810 // Skip repos that don't match the filter.
811 // BORROW: explicit .as_str() instead of Deref coercion
812 if let Some(filter) = repo_filter {
813 if repo_id.as_str() != filter {
814 continue;
815 }
816 }
817
818 let blobs_dir = crate::cache_layout::blobs_dir(&entry.path());
819 let Ok(blob_entries) = std::fs::read_dir(&blobs_dir) else {
820 continue;
821 };
822
823 for blob_entry in blob_entries {
824 let Ok(blob_entry) = blob_entry else { continue };
825 let name = blob_entry.file_name();
826 // BORROW: explicit .to_string_lossy() for OsString → str conversion
827 let name_str = name.to_string_lossy();
828 if name_str.ends_with(".chunked.part") {
829 let size = blob_entry.metadata().map_or(0, |m| m.len());
830 partials.push(PartialFile {
831 // BORROW: explicit .clone() for owned String
832 repo_id: repo_id.clone(),
833 // BORROW: explicit .to_string() for Cow<str> → owned String
834 filename: name_str.to_string(),
835 path: blob_entry.path(),
836 size,
837 });
838 }
839 }
840 }
841
842 Ok(partials)
843}
844
845/// Per-file disk usage entry within a cached repository.
846#[derive(Debug, Clone)]
847pub struct CacheFileUsage {
848 /// Filename relative to the snapshot directory.
849 pub filename: String,
850 /// File size in bytes.
851 pub size: u64,
852}
853
854/// Returns per-file disk usage for a specific cached repository.
855///
856/// Walks the snapshot directories under
857/// `<cache_dir>/models--<org>--<name>/snapshots/` and collects each file's
858/// relative path and size. Results are sorted by size descending.
859///
860/// Returns an empty `Vec` if the repository is not cached.
861///
862/// # Errors
863///
864/// Returns [`FetchError::Io`] if the cache directory cannot be determined.
865pub fn cache_repo_usage(repo_id: &str) -> Result<Vec<CacheFileUsage>, FetchError> {
866 let cache_dir = hf_cache_dir()?;
867 let repo_dir = crate::cache_layout::repo_dir(&cache_dir, repo_id);
868
869 if !repo_dir.exists() {
870 return Ok(Vec::new());
871 }
872
873 let snapshots_dir = crate::cache_layout::snapshots_dir(&repo_dir);
874 let Ok(snapshots) = std::fs::read_dir(&snapshots_dir) else {
875 return Ok(Vec::new());
876 };
877
878 let mut files: Vec<CacheFileUsage> = Vec::new();
879
880 for snap_entry in snapshots {
881 let Ok(snap_entry) = snap_entry else { continue };
882 let snap_path = snap_entry.path();
883 if !snap_path.is_dir() {
884 continue;
885 }
886 collect_snapshot_files(&snap_path, "", &mut files);
887 }
888
889 files.sort_by_key(|f| std::cmp::Reverse(f.size));
890
891 Ok(files)
892}
893
894/// Recursively collects files from a snapshot directory into `CacheFileUsage` entries.
895///
896/// The `prefix` parameter tracks the relative path from the snapshot root,
897/// so that files in subdirectories get paths like `"tokenizer/vocab.json"`.
898fn collect_snapshot_files(dir: &Path, prefix: &str, files: &mut Vec<CacheFileUsage>) {
899 let Ok(entries) = std::fs::read_dir(dir) else {
900 return;
901 };
902
903 for entry in entries {
904 let Ok(entry) = entry else { continue };
905 let path = entry.path();
906 // BORROW: explicit .to_string_lossy() for OsString → str conversion
907 let name = entry.file_name().to_string_lossy().to_string();
908
909 if path.is_dir() {
910 let child_prefix = if prefix.is_empty() {
911 name
912 } else {
913 format!("{prefix}/{name}")
914 };
915 collect_snapshot_files(&path, &child_prefix, files);
916 } else {
917 let filename = if prefix.is_empty() {
918 name
919 } else {
920 format!("{prefix}/{name}")
921 };
922 let size = entry.metadata().map_or(0, |m| m.len());
923 files.push(CacheFileUsage { filename, size });
924 }
925 }
926}
927
928/// Verification status for a single cached file.
929#[non_exhaustive]
930#[derive(Debug, Clone)]
931pub enum VerifyStatus {
932 /// Local `SHA256` matches the expected hash from `HuggingFace` LFS metadata.
933 Ok,
934 /// Local `SHA256` does not match the expected hash — the cached file is
935 /// corrupted (bit rot, interrupted write, or upstream blob changed).
936 Mismatch {
937 /// Expected `SHA256` hex digest from `HuggingFace` LFS metadata.
938 expected: String,
939 /// Actual `SHA256` hex digest computed from the local file.
940 actual: String,
941 },
942 /// File has no LFS metadata (small git-stored file); verification skipped.
943 Skipped,
944 /// File is absent from the local snapshot directory.
945 Missing,
946}
947
948/// Result of verifying a single cached file against `HuggingFace` LFS metadata.
949#[derive(Debug, Clone)]
950pub struct FileVerification {
951 /// Filename within the repository.
952 pub filename: String,
953 /// File size in bytes — local size when the file is present, otherwise
954 /// the expected size from the API (or `0` when neither is known).
955 pub size: u64,
956 /// Verification result.
957 pub status: VerifyStatus,
958}
959
960/// Streaming progress event emitted by [`verify_cache_with_progress`] so
961/// callers can render per-file feedback during a long verification.
962///
963/// Events fire in this order:
964/// 1. [`VerifyEvent::Started`] — once, after the metadata fetch completes,
965/// before any per-file work begins. Carries the total file count and a
966/// pre-computed maximum filename length so callers can size display
967/// columns up-front.
968/// 2. For each file in alphabetical order:
969/// - [`VerifyEvent::FileStart`] — before the per-file `SHA256`
970/// computation kicks in.
971/// - [`VerifyEvent::FileComplete`] — when the per-file result is known,
972/// carrying the [`VerifyStatus`] outcome.
973#[non_exhaustive]
974#[derive(Debug)]
975pub enum VerifyEvent<'a> {
976 /// Fired once at the start of the run with summary stats useful for
977 /// laying out a streamed table or progress display.
978 Started {
979 /// Total number of files that will be verified.
980 total: usize,
981 /// Maximum filename length across the verification list.
982 max_filename_len: usize,
983 },
984 /// A file is about to be verified.
985 FileStart {
986 /// 1-based index of this file in the verification list.
987 index: usize,
988 /// Total number of files in the verification list.
989 total: usize,
990 /// Filename within the repository.
991 filename: &'a str,
992 /// File size in bytes (local size when present, else expected size).
993 size: u64,
994 /// `true` when the file has LFS metadata (a real `SHA256` computation
995 /// is about to run); `false` when the file is git-stored and will be
996 /// skipped near-instantly.
997 has_lfs: bool,
998 },
999 /// A file's verification has completed.
1000 FileComplete {
1001 /// 1-based index of this file in the verification list.
1002 index: usize,
1003 /// Total number of files in the verification list.
1004 total: usize,
1005 /// Filename within the repository.
1006 filename: &'a str,
1007 /// File size in bytes (matches the `size` from the corresponding
1008 /// [`VerifyEvent::FileStart`]).
1009 size: u64,
1010 /// The per-file verification result.
1011 status: &'a VerifyStatus,
1012 },
1013}
1014
1015/// Verifies `SHA256` digests of cached files against `HuggingFace` LFS metadata.
1016///
1017/// Fetches the expected hashes from the `HuggingFace` API and, for each file
1018/// that has an LFS `SHA256`, reads the local cached file and compares.
1019///
1020/// Files without LFS metadata (small git-stored files such as `config.json`)
1021/// are reported as [`VerifyStatus::Skipped`]; files absent from the snapshot
1022/// directory are reported as [`VerifyStatus::Missing`]. Both are
1023/// non-failures — only [`VerifyStatus::Mismatch`] indicates a corrupted file.
1024///
1025/// `revision` defaults to `"main"` when `None`. Requires network access for
1026/// the metadata fetch; the per-file digest computation is local-only.
1027///
1028/// For long verifications (multi-GiB safetensors files), prefer
1029/// [`verify_cache_with_progress`] so a CLI / GUI can render a spinner or
1030/// progress bar while each file is hashed.
1031///
1032/// # Errors
1033///
1034/// Returns [`FetchError::Http`] if the `HuggingFace` API request fails.
1035/// Returns [`FetchError::Io`] when a local cached file is present but
1036/// cannot be read.
1037pub async fn verify_cache(
1038 repo_id: &str,
1039 token: Option<&str>,
1040 revision: Option<&str>,
1041) -> Result<Vec<FileVerification>, FetchError> {
1042 verify_cache_with_progress(repo_id, token, revision, |_| {}).await
1043}
1044
1045/// Same as [`verify_cache`] but emits [`VerifyEvent`]s through `on_event`
1046/// so callers can render streaming progress (e.g. a spinner per file).
1047///
1048/// The callback runs on the same task as the verification — keep it short.
1049/// Use interior mutability ([`std::cell::Cell`], [`std::cell::RefCell`]) if
1050/// you need to track state across events; the closure may capture by shared
1051/// reference because the API requires only [`Fn`].
1052///
1053/// Files are processed in alphabetical order by filename so that streamed
1054/// output remains stable across runs and matches the sort order of the
1055/// returned [`Vec<FileVerification>`].
1056///
1057/// # Errors
1058///
1059/// Same error conditions as [`verify_cache`].
1060pub async fn verify_cache_with_progress<F>(
1061 repo_id: &str,
1062 token: Option<&str>,
1063 revision: Option<&str>,
1064 on_event: F,
1065) -> Result<Vec<FileVerification>, FetchError>
1066where
1067 F: Fn(VerifyEvent<'_>),
1068{
1069 let revision = revision.unwrap_or("main");
1070 let cache_dir = hf_cache_dir()?;
1071 let repo_dir = crate::cache_layout::repo_dir(&cache_dir, repo_id);
1072
1073 let commit_hash = read_ref(&repo_dir, revision);
1074
1075 let client = crate::chunked::build_client(token)?;
1076 let mut remote_files =
1077 crate::repo::list_repo_files_with_metadata(repo_id, token, Some(revision), &client).await?;
1078
1079 // Sort up-front so streamed output is stable across runs and matches the
1080 // returned `Vec<FileVerification>`'s order.
1081 remote_files.sort_by(|a, b| a.filename.cmp(&b.filename));
1082
1083 // BORROW: explicit .as_deref() for Option<String> → Option<&str>
1084 let snapshot_dir = commit_hash
1085 .as_deref()
1086 .map(|hash| crate::cache_layout::snapshot_dir(&repo_dir, hash));
1087
1088 let total = remote_files.len();
1089 let max_filename_len = remote_files
1090 .iter()
1091 .map(|f| f.filename.len())
1092 .max()
1093 .unwrap_or(0);
1094
1095 on_event(VerifyEvent::Started {
1096 total,
1097 max_filename_len,
1098 });
1099
1100 let mut results: Vec<FileVerification> = Vec::with_capacity(total);
1101
1102 for (i, remote) in remote_files.iter().enumerate() {
1103 let index = i + 1;
1104 let local_path = snapshot_dir
1105 .as_ref()
1106 // BORROW: explicit .as_str() for path construction
1107 .map(|dir| dir.join(remote.filename.as_str()));
1108
1109 let exists = local_path.as_ref().is_some_and(|p| p.exists());
1110 let local_size = local_path
1111 .as_ref()
1112 .filter(|_| exists)
1113 .and_then(|p| std::fs::metadata(p).ok().map(|m| m.len()))
1114 .unwrap_or(0);
1115 let expected_size = remote.size.unwrap_or(0);
1116 let display_size = if exists { local_size } else { expected_size };
1117
1118 let has_lfs = remote.sha256.is_some();
1119 on_event(VerifyEvent::FileStart {
1120 index,
1121 total,
1122 // BORROW: explicit .as_str() for &String → &str argument
1123 filename: remote.filename.as_str(),
1124 size: display_size,
1125 has_lfs,
1126 });
1127
1128 let status = match (remote.sha256.as_deref(), local_path.as_deref(), exists) {
1129 (None, _, _) => VerifyStatus::Skipped,
1130 (Some(_), None, _) | (Some(_), Some(_), false) => VerifyStatus::Missing,
1131 (Some(expected), Some(path), true) => {
1132 // BORROW: explicit .as_str() for &String → &str argument
1133 match crate::checksum::verify_sha256(path, remote.filename.as_str(), expected).await
1134 {
1135 Ok(()) => VerifyStatus::Ok,
1136 Err(FetchError::Checksum {
1137 expected, actual, ..
1138 }) => VerifyStatus::Mismatch { expected, actual },
1139 Err(e) => return Err(e),
1140 }
1141 }
1142 };
1143
1144 on_event(VerifyEvent::FileComplete {
1145 index,
1146 total,
1147 // BORROW: explicit .as_str() for &String → &str argument
1148 filename: remote.filename.as_str(),
1149 size: display_size,
1150 status: &status,
1151 });
1152
1153 results.push(FileVerification {
1154 // BORROW: explicit .clone() for owned String
1155 filename: remote.filename.clone(),
1156 size: display_size,
1157 status,
1158 });
1159 }
1160
1161 Ok(results)
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166 #![allow(
1167 clippy::panic,
1168 clippy::unwrap_used,
1169 clippy::expect_used,
1170 clippy::indexing_slicing
1171 )]
1172
1173 use super::*;
1174
1175 fn sample_partial(filename: &str) -> PartialFile {
1176 PartialFile {
1177 repo_id: "org/model".to_owned(),
1178 filename: filename.to_owned(),
1179 path: PathBuf::from("/tmp/models--org--model/blobs").join(filename),
1180 size: 1024,
1181 }
1182 }
1183
1184 #[test]
1185 fn sidecar_paths_returns_state_and_state_tmp() {
1186 let p = sample_partial("abc123.chunked.part");
1187 let sidecars = p.sidecar_paths();
1188
1189 assert_eq!(sidecars.len(), 2);
1190 assert_eq!(
1191 sidecars[0],
1192 PathBuf::from("/tmp/models--org--model/blobs/abc123.chunked.part.state")
1193 );
1194 assert_eq!(
1195 sidecars[1],
1196 PathBuf::from("/tmp/models--org--model/blobs/abc123.chunked.part.state.tmp")
1197 );
1198 }
1199
1200 #[test]
1201 fn sidecar_paths_handles_etag_with_periods() {
1202 // Same period-handling rationale as `cache_layout::temp_state_path`:
1203 // the etag may itself contain dots, so naive `Path::with_extension`
1204 // would chop at the wrong boundary.
1205 let p = sample_partial("abc.def.chunked.part");
1206 let sidecars = p.sidecar_paths();
1207
1208 assert_eq!(
1209 sidecars[0],
1210 PathBuf::from("/tmp/models--org--model/blobs/abc.def.chunked.part.state")
1211 );
1212 assert_eq!(
1213 sidecars[1],
1214 PathBuf::from("/tmp/models--org--model/blobs/abc.def.chunked.part.state.tmp")
1215 );
1216 }
1217
1218 #[test]
1219 fn snapshot_roundtrip_write_then_read_returns_equal_value() {
1220 // Use a freshly-created temp dir so the test is isolated from any
1221 // pre-existing cache state on the developer machine / CI runner.
1222 let tmp =
1223 std::env::temp_dir().join(format!("hf-fm-snapshot-roundtrip-{}", std::process::id()));
1224 std::fs::create_dir_all(&tmp).expect("create temp dir");
1225
1226 let original = Snapshot {
1227 version: SNAPSHOT_VERSION,
1228 revision: "main".to_owned(),
1229 preset: Some("safetensors".to_owned()),
1230 filter: vec!["*.json".to_owned()],
1231 exclude: vec!["*.md".to_owned()],
1232 };
1233
1234 write_snapshot(&tmp, &original).expect("write_snapshot");
1235 let round_tripped = read_snapshot(&tmp)
1236 .expect("read_snapshot")
1237 .expect("snapshot present");
1238
1239 assert_eq!(round_tripped, original);
1240
1241 // Cleanup
1242 let _ = std::fs::remove_file(snapshot_path(&tmp));
1243 let _ = std::fs::remove_dir(&tmp);
1244 }
1245
1246 #[test]
1247 fn snapshot_read_returns_none_when_absent() {
1248 let tmp =
1249 std::env::temp_dir().join(format!("hf-fm-snapshot-absent-{}", std::process::id()));
1250 std::fs::create_dir_all(&tmp).expect("create temp dir");
1251
1252 let result = read_snapshot(&tmp).expect("read_snapshot");
1253 assert!(result.is_none(), "expected None for absent sidecar");
1254
1255 let _ = std::fs::remove_dir(&tmp);
1256 }
1257}