Skip to main content

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}