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), via the shared
88/// `atomic_write::write_atomic_sync` helper — the same durability
89/// pattern `chunked_state::ChunkedState::save_atomic` and
90/// [`crate::header_cache::HeaderCacheEntry::save_atomic`] use, synchronous
91/// here since this call site has no `tokio` runtime handy.
92///
93/// Overwrites any previously-written sidecar — the design is intentionally
94/// last-download-wins. `repo_dir`'s parent directory is created first (a
95/// no-op if it already exists), matching the other two sidecars' behavior.
96///
97/// # Errors
98///
99/// Returns [`FetchError::InvalidArgument`] if `snapshot` fails to
100/// serialize (would indicate a programmer bug — every field of
101/// [`Snapshot`] is plain-old-data).
102/// Returns [`FetchError::Io`] if `repo_dir`'s parent directory cannot be
103/// created.
104/// Returns [`FetchError::Io`] if the temp write fails (no write
105/// permission, disk full, etc.).
106/// Returns [`FetchError::Io`] if the rename fails (e.g. across
107/// filesystems).
108pub fn write_snapshot(repo_dir: &Path, snapshot: &Snapshot) -> Result<(), FetchError> {
109    let path = snapshot_path(repo_dir);
110    let tmp = path.with_extension("json.tmp");
111    let bytes = serde_json::to_vec_pretty(snapshot)
112        .map_err(|e| FetchError::InvalidArgument(format!("failed to serialize snapshot: {e}")))?;
113    crate::atomic_write::write_atomic_sync(&path, &tmp, &bytes)
114}
115
116/// Reconstructs a repo ID from a `models--org--name` directory name.
117///
118/// Returns `None` if the directory name does not start with `models--`.
119fn repo_id_from_folder_name(dir_name: &str) -> Option<String> {
120    let repo_part = dir_name.strip_prefix("models--")?;
121
122    // Reconstruct repo_id: replace first "--" with "/".
123    let repo_id = match repo_part.find("--") {
124        Some(pos) => {
125            let (org, name_with_sep) = repo_part.split_at(pos);
126            let name = name_with_sep.get(2..).unwrap_or_default();
127            format!("{org}/{name}")
128        }
129        None => repo_part.to_string(),
130    };
131
132    Some(repo_id)
133}
134
135/// Returns the `HuggingFace` Hub cache directory.
136///
137/// Resolution order:
138/// 1. `HF_HOME` environment variable + `/hub`
139/// 2. `~/.cache/huggingface/hub/` (via [`dirs::home_dir()`])
140///
141/// # Errors
142///
143/// Returns [`FetchError::Io`] if the home directory cannot be determined.
144pub fn hf_cache_dir() -> Result<PathBuf, FetchError> {
145    if let Ok(home) = std::env::var("HF_HOME") {
146        let mut path = PathBuf::from(home);
147        path.push("hub");
148        return Ok(path);
149    }
150
151    let home = dirs::home_dir().ok_or_else(|| FetchError::Io {
152        path: PathBuf::from("~"),
153        source: std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found"),
154    })?;
155
156    let mut path = home;
157    path.push(".cache");
158    path.push("huggingface");
159    path.push("hub");
160    Ok(path)
161}
162
163/// One repository inside a cached family, with optional quantization label.
164///
165/// Returned by [`list_cached_families`] so callers can render a quant column
166/// alongside the repo ID without re-reading every snapshot's `config.json`
167/// in a second pass.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct FamilyEntry {
170    /// The repository identifier (e.g., `"meta-llama/Llama-3.2-1B"`).
171    pub repo_id: String,
172    /// Quantization method as reported by `quantization_config.quant_method`
173    /// in the cached `config.json`. Falls back to `"gguf"` when any cached
174    /// file in the newest snapshot directory has a `.gguf` extension.
175    /// `None` for full-precision repos.
176    pub quant_method: Option<String>,
177}
178
179/// Scans the local HF cache for downloaded models and groups them by `model_type`.
180///
181/// Looks for `config.json` files inside model snapshot directories:
182/// `<cache>/models--<org>--<name>/snapshots/*/config.json`
183///
184/// Returns a map from `model_type` (e.g., `"llama"`) to a sorted list of
185/// [`FamilyEntry`] values, each pairing a repo ID with its quantization
186/// label (if any).
187///
188/// Models without a `model_type` field in their `config.json` are skipped.
189///
190/// # Errors
191///
192/// Returns [`FetchError::Io`] if the cache directory cannot be read.
193pub fn list_cached_families() -> Result<BTreeMap<String, Vec<FamilyEntry>>, FetchError> {
194    let cache_dir = hf_cache_dir()?;
195
196    if !cache_dir.exists() {
197        return Ok(BTreeMap::new());
198    }
199
200    let entries = std::fs::read_dir(&cache_dir).map_err(|e| FetchError::Io {
201        path: cache_dir.clone(),
202        source: e,
203    })?;
204
205    let mut families: BTreeMap<String, Vec<FamilyEntry>> = BTreeMap::new();
206
207    for entry in entries {
208        let Ok(entry) = entry else { continue };
209
210        let dir_name = entry.file_name();
211        // BORROW: explicit .to_string_lossy() for OsString → str conversion
212        let dir_str = dir_name.to_string_lossy();
213
214        let Some(repo_id) = repo_id_from_folder_name(&dir_str) else {
215            continue;
216        };
217
218        // Find the newest snapshot's config.json
219        let snapshots_dir = crate::cache_layout::snapshots_dir(&entry.path());
220        if !snapshots_dir.exists() {
221            continue;
222        }
223
224        if let Some((model_type, quant_method)) = find_family_info_in_snapshots(&snapshots_dir) {
225            families.entry(model_type).or_default().push(FamilyEntry {
226                repo_id,
227                quant_method,
228            });
229        }
230    }
231
232    // Sort repo lists within each family for stable output
233    for entries in families.values_mut() {
234        entries.sort_by(|a, b| a.repo_id.cmp(&b.repo_id));
235    }
236
237    Ok(families)
238}
239
240/// Searches snapshot directories for a `config.json` containing `model_type`,
241/// and reports an accompanying quantization label when one can be inferred.
242///
243/// Returns the first `(model_type, quant_method)` pair found. The quant
244/// label comes from `quantization_config.quant_method` in `config.json`,
245/// or falls back to `Some("gguf".to_owned())` when any sibling file in the
246/// same snapshot directory has a `.gguf` extension. Returns `None` if no
247/// snapshot yields a parseable `model_type`.
248fn find_family_info_in_snapshots(
249    snapshots_dir: &std::path::Path,
250) -> Option<(String, Option<String>)> {
251    let snapshots = std::fs::read_dir(snapshots_dir).ok()?;
252
253    for snap_entry in snapshots {
254        let Ok(snap_entry) = snap_entry else { continue };
255        let snap_path = snap_entry.path();
256        let config_path = snap_path.join("config.json");
257
258        if !config_path.exists() {
259            continue;
260        }
261
262        if let Some(model_type) = extract_model_type(&config_path) {
263            let quant_method = extract_quant_method(&config_path)
264                .or_else(|| snapshot_has_gguf(&snap_path).then(|| "gguf".to_owned())); // BORROW: explicit .to_owned()
265            return Some((model_type, quant_method));
266        }
267    }
268
269    None
270}
271
272/// Reads a `config.json` file and extracts the `model_type` field.
273fn extract_model_type(config_path: &std::path::Path) -> Option<String> {
274    let contents = std::fs::read_to_string(config_path).ok()?;
275    // BORROW: explicit .as_str() instead of Deref coercion
276    let value: serde_json::Value = serde_json::from_str(contents.as_str()).ok()?;
277    // BORROW: explicit .as_str() on serde_json Value
278    value.get("model_type")?.as_str().map(String::from)
279}
280
281/// Reads a `config.json` file and extracts the transformers-standard
282/// `quantization_config.quant_method` field, if present.
283///
284/// Returns `None` when the file is unreadable, malformed, or contains no
285/// quantization config (the repo is treated as full-precision in that case;
286/// the GGUF filename fallback is applied separately by the caller).
287fn extract_quant_method(config_path: &std::path::Path) -> Option<String> {
288    let contents = std::fs::read_to_string(config_path).ok()?;
289    // BORROW: explicit .as_str() instead of Deref coercion
290    let value: serde_json::Value = serde_json::from_str(contents.as_str()).ok()?;
291    // BORROW: explicit .as_str() on serde_json Value
292    value
293        .get("quantization_config")?
294        .get("quant_method")?
295        .as_str()
296        .map(String::from)
297}
298
299/// Returns `true` if any file in the snapshot directory has a `.gguf` extension.
300///
301/// Used as a fallback quant label when `config.json` carries no
302/// `quantization_config` (GGUF repos typically lack a transformers-style
303/// `config.json` `quantization_config` block). Extension comparison is
304/// case-insensitive via `OsStr::eq_ignore_ascii_case`.
305fn snapshot_has_gguf(snapshot_dir: &std::path::Path) -> bool {
306    let Ok(entries) = std::fs::read_dir(snapshot_dir) else {
307        return false;
308    };
309    for entry in entries.flatten() {
310        let path = entry.path();
311        if path
312            .extension()
313            .is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
314        {
315            return true;
316        }
317    }
318    false
319}
320
321/// Status of a single file in the cache.
322#[derive(Debug, Clone)]
323#[non_exhaustive]
324pub enum FileStatus {
325    /// File is fully downloaded (local size matches expected size, or no expected size known).
326    Complete {
327        /// Local file size in bytes.
328        local_size: u64,
329    },
330    /// File exists but is smaller than expected (interrupted download),
331    /// or the file's own `blobs/<sha256>.chunked.part` temp blob exists
332    /// (keyed on the file's LFS `sha256` — per-file attribution).
333    Partial {
334        /// Local file size in bytes.
335        local_size: u64,
336        /// Expected file size in bytes.
337        expected_size: u64,
338    },
339    /// File is not present in the cache.
340    Missing {
341        /// Expected file size in bytes (0 if unknown).
342        expected_size: u64,
343    },
344    /// File is on the Hub but was deliberately not requested at download time
345    /// (or is now filtered out by `status --preset <P>`). Distinguished from
346    /// [`FileStatus::Missing`] so the user does not chase a "fix" for an
347    /// intentional skip.
348    Excluded {
349        /// Expected file size in bytes (0 if unknown).
350        expected_size: u64,
351    },
352}
353
354/// Cache status report for a repository.
355#[derive(Debug, Clone)]
356pub struct RepoStatus {
357    /// The repository identifier.
358    pub repo_id: String,
359    /// The resolved commit hash (if available).
360    pub commit_hash: Option<String>,
361    /// The cache directory for this repo.
362    pub cache_path: PathBuf,
363    /// Per-file status, sorted by filename.
364    pub files: Vec<(String, FileStatus)>,
365}
366
367impl RepoStatus {
368    /// Number of fully downloaded files.
369    #[must_use]
370    pub fn complete_count(&self) -> usize {
371        self.files
372            .iter()
373            .filter(|(_, s)| matches!(s, FileStatus::Complete { .. }))
374            .count()
375    }
376
377    /// Number of partially downloaded files.
378    #[must_use]
379    pub fn partial_count(&self) -> usize {
380        self.files
381            .iter()
382            .filter(|(_, s)| matches!(s, FileStatus::Partial { .. }))
383            .count()
384    }
385
386    /// Number of missing files.
387    #[must_use]
388    pub fn missing_count(&self) -> usize {
389        self.files
390            .iter()
391            .filter(|(_, s)| matches!(s, FileStatus::Missing { .. }))
392            .count()
393    }
394
395    /// Number of files deliberately excluded by the active preset / filter.
396    ///
397    /// Always `0` when `status` is invoked without a preset (whether from
398    /// CLI or sidecar) — the `Excluded` variant requires an active filter.
399    #[must_use]
400    pub fn excluded_count(&self) -> usize {
401        self.files
402            .iter()
403            .filter(|(_, s)| matches!(s, FileStatus::Excluded { .. }))
404            .count()
405    }
406}
407
408/// Inspects the local cache for a repository and compares against the remote file list.
409///
410/// When `preset_globs` is `Some`, files that do **not** match any of the
411/// supplied glob patterns and are absent locally are classified as
412/// [`FileStatus::Excluded`] instead of [`FileStatus::Missing`]. Files that
413/// **do** match the preset and are absent are still [`FileStatus::Missing`].
414/// Files that are present locally classify as `Complete` / `Partial`
415/// regardless of whether they match (the user has them — by what route is
416/// not status's concern).
417///
418/// # Arguments
419///
420/// * `repo_id` — The repository identifier (e.g., `"RWKV/RWKV7-Goose-World3-1.5B-HF"`).
421/// * `token` — Optional authentication token.
422/// * `revision` — Optional revision (defaults to `"main"`).
423/// * `preset_globs` — Optional include-glob list (typically returned by
424///   [`crate::config::preset_globs`]). When supplied, governs the
425///   `Excluded` distinction. When `None`, no `Excluded` entries are produced.
426///
427/// # Notes
428///
429/// Partial-download detection is per-file: a file absent from the snapshot
430/// directory is reported [`FileStatus::Partial`] only when its own
431/// chunked-download temp blob (`blobs/<sha256>.chunked.part`, keyed on the
432/// file's LFS `sha256`) exists on disk, and `local_size` is that temp
433/// blob's current byte count. Non-LFS files carry no `sha256`, so an
434/// interrupted non-chunked download reports [`FileStatus::Missing`] until
435/// it finalizes.
436///
437/// # Errors
438///
439/// Returns [`FetchError::Http`] if the API request fails.
440/// Returns [`FetchError::Io`] if the cache directory cannot be read.
441/// Returns [`FetchError::InvalidPattern`]
442/// if any of the supplied `preset_globs` patterns fails to compile.
443pub async fn repo_status(
444    repo_id: &str,
445    token: Option<&str>,
446    revision: Option<&str>,
447    preset_globs: Option<&[&str]>,
448) -> Result<RepoStatus, FetchError> {
449    let revision = revision.unwrap_or("main");
450    let cache_dir = hf_cache_dir()?;
451    let repo_dir = crate::cache_layout::repo_dir(&cache_dir, repo_id);
452
453    // Read commit hash from refs file if available.
454    let commit_hash = read_ref(&repo_dir, revision);
455
456    // Fetch remote file list with sizes.
457    let client = crate::chunked::build_client(token)?;
458    let remote_files =
459        crate::repo::list_repo_files_with_metadata(repo_id, token, Some(revision), &client).await?;
460
461    // Determine snapshot directory.
462    // BORROW: explicit .as_deref() for Option<String> → Option<&str>
463    let snapshot_dir = commit_hash
464        .as_deref()
465        .map(|hash| crate::cache_layout::snapshot_dir(&repo_dir, hash));
466
467    // Compile preset globs once, outside the per-file loop. `compile_glob_patterns`
468    // already returns `Ok(None)` for empty slices, so an empty `Some(&[])`
469    // collapses to the "no filter" case below.
470    let preset_globset: Option<globset::GlobSet> = if let Some(patterns) = preset_globs {
471        // BORROW: explicit .to_string() — compile_glob_patterns expects &[String]
472        let owned: Vec<String> = patterns.iter().map(|s| (*s).to_string()).collect();
473        crate::compile_glob_patterns(&owned)?
474    } else {
475        None
476    };
477
478    // Cross-reference remote files against local state.
479    let mut files: Vec<(String, FileStatus)> = Vec::with_capacity(remote_files.len());
480
481    for remote in &remote_files {
482        let expected_size = remote.size.unwrap_or(0);
483
484        let local_path = snapshot_dir
485            .as_ref()
486            // BORROW: explicit .as_str() for path construction
487            .map(|dir| dir.join(remote.filename.as_str()));
488
489        // Size of the snapshot copy when it exists; `None` when the file is
490        // absent (or no snapshot directory is known for this revision).
491        let snapshot_size = local_path.as_ref().and_then(|path| {
492            path.exists()
493                .then(|| std::fs::metadata(path).map_or(0, |m| m.len()))
494        });
495
496        let status = if let Some(local_size) = snapshot_size {
497            if expected_size > 0 && local_size < expected_size {
498                FileStatus::Partial {
499                    local_size,
500                    expected_size,
501                }
502            } else {
503                FileStatus::Complete { local_size }
504            }
505        } else if let Some(part_size) =
506            // BORROW: explicit .as_deref() for Option<String> → Option<&str>
507            partial_chunk_size(&repo_dir, remote.sha256.as_deref())
508        {
509            // This file's own chunked-download temp blob is on disk: attribute
510            // exactly its byte count to this file (keyed on the LFS sha256).
511            FileStatus::Partial {
512                local_size: part_size,
513                expected_size,
514            }
515        } else if preset_globset
516            .as_ref()
517            // BORROW: explicit .as_str() — globset matches against &str
518            .is_some_and(|gs| !gs.is_match(remote.filename.as_str()))
519        {
520            // File is absent AND the active preset deliberately excludes it.
521            FileStatus::Excluded { expected_size }
522        } else {
523            FileStatus::Missing { expected_size }
524        };
525
526        // BORROW: explicit .clone() for owned String
527        files.push((remote.filename.clone(), status));
528    }
529
530    files.sort_by(|(a, _), (b, _)| a.cmp(b));
531
532    // BORROW: explicit .to_owned() for &str → owned String field
533    Ok(RepoStatus {
534        repo_id: repo_id.to_owned(),
535        commit_hash,
536        cache_path: repo_dir,
537        files,
538    })
539}
540
541/// Summary of a single cached model (local-only, no API calls).
542#[derive(Debug, Clone)]
543pub struct CachedModelSummary {
544    /// The repository identifier (e.g., `"RWKV/RWKV7-Goose-World3-1.5B-HF"`).
545    pub repo_id: String,
546    /// Number of files in the snapshot directory.
547    pub file_count: usize,
548    /// Total size on disk in bytes.
549    pub total_size: u64,
550    /// Whether there are incomplete `.chunked.part` temp files.
551    pub has_partial: bool,
552    /// Most recent modification time among files in the snapshot directory.
553    ///
554    /// `None` if no files were found or all metadata reads failed.
555    pub last_modified: Option<std::time::SystemTime>,
556    /// `(min, max)` size across this repo's `.gguf` files, when they are
557    /// mutually-exclusive quant alternatives rather than shards of one
558    /// logical file (see [`crate::discover::gguf_size_range`]). `None` when
559    /// not applicable — `total_size` is already the correct figure then.
560    pub gguf_size_range: Option<(u64, u64)>,
561}
562
563/// Scans the entire HF cache and returns a summary for each cached model.
564///
565/// This is a local-only operation (no API calls). It lists all `models--*`
566/// directories and counts files + sizes in each snapshot.
567///
568/// # Errors
569///
570/// Returns [`FetchError::Io`] if the cache directory cannot be read.
571pub fn cache_summary() -> Result<Vec<CachedModelSummary>, FetchError> {
572    let cache_dir = hf_cache_dir()?;
573
574    if !cache_dir.exists() {
575        return Ok(Vec::new());
576    }
577
578    let entries = std::fs::read_dir(&cache_dir).map_err(|e| FetchError::Io {
579        path: cache_dir.clone(),
580        source: e,
581    })?;
582
583    let mut summaries: Vec<CachedModelSummary> = Vec::new();
584
585    for entry in entries {
586        let Ok(entry) = entry else { continue };
587        let dir_name = entry.file_name();
588        // BORROW: explicit .to_string_lossy() for OsString → str conversion
589        let dir_str = dir_name.to_string_lossy();
590
591        let Some(repo_id) = repo_id_from_folder_name(&dir_str) else {
592            continue;
593        };
594
595        let repo_dir = entry.path();
596
597        // Single walk over the snapshot tree for file_count/total_size/
598        // last_modified and the raw filenames needed to classify `.gguf`
599        // quant alternatives — previously two separate recursive walks
600        // over the identical directory tree.
601        let RepoFileWalk {
602            files: cached_files,
603            total_size,
604            last_modified,
605        } = walk_repo_files(&repo_dir);
606        let file_count = cached_files.len();
607        // BORROW: explicit .as_str() instead of Deref coercion
608        let sized: Vec<(&str, Option<u64>)> = cached_files
609            .iter()
610            .map(|f| (f.filename.as_str(), Some(f.size)))
611            .collect();
612        let gguf_size_range = crate::discover::gguf_size_range(sized);
613
614        // Check for partial downloads.
615        let has_partial = find_partial_blob_size(&crate::cache_layout::blobs_dir(&repo_dir)) > 0;
616
617        // Deliberately NOT filtering out zero-byte, non-partial repos here
618        // (e.g. a repo whose directory exists solely because of an
619        // `inspect --cache-headers` sidecar, never downloaded): `cache gc`
620        // and `status` both consume this same list, and a repo invisible
621        // here would be unreachable by bulk eviction — the header-cache
622        // sidecar could grow unboundedly (new etags are never cleaned up)
623        // with no way to reclaim it short of `cache delete <repo>` by exact
624        // ID. `du`'s own summary view filters purely cosmetic zero-byte
625        // entries out at its own call site instead, leaving this shared
626        // data layer complete.
627        summaries.push(CachedModelSummary {
628            repo_id,
629            file_count,
630            total_size,
631            has_partial,
632            last_modified,
633            gguf_size_range,
634        });
635    }
636
637    summaries.sort_by(|a, b| a.repo_id.cmp(&b.repo_id));
638
639    Ok(summaries)
640}
641
642/// Returns the file count and total size for a single cached repo.
643///
644/// Avoids scanning the entire cache when only one repo's metrics are needed
645/// (e.g., for the `cache delete` preview).
646///
647/// # Errors
648///
649/// Returns [`FetchError::Io`] if the cache directory cannot be determined.
650pub fn repo_disk_usage(repo_id: &str) -> Result<(usize, u64), FetchError> {
651    let cache_dir = hf_cache_dir()?;
652    let repo_dir = crate::cache_layout::repo_dir(&cache_dir, repo_id);
653    let walk = walk_repo_files(&repo_dir);
654    Ok((walk.files.len(), walk.total_size))
655}
656
657/// Checks whether a single cached repo has `.chunked.part` temp files.
658///
659/// Avoids scanning the entire cache when only one repo's partial status
660/// is needed (e.g., for the `du <REPO>` partial-download hint).
661///
662/// # Errors
663///
664/// Returns [`FetchError::Io`] if the cache directory cannot be determined.
665pub fn repo_has_partial(repo_id: &str) -> Result<bool, FetchError> {
666    let cache_dir = hf_cache_dir()?;
667    let repo_dir = crate::cache_layout::repo_dir(&cache_dir, repo_id);
668    let blobs_dir = crate::cache_layout::blobs_dir(&repo_dir);
669    Ok(find_partial_blob_size(&blobs_dir) > 0)
670}
671
672/// Reads the commit hash from a refs file, if it exists.
673///
674/// Looks for `<repo_dir>/refs/<revision>` and returns the trimmed contents
675/// (a commit hash) or `None` if the file does not exist or is empty.
676#[must_use]
677pub fn read_ref(repo_dir: &Path, revision: &str) -> Option<String> {
678    let ref_path = crate::cache_layout::ref_path(repo_dir, revision);
679    std::fs::read_to_string(ref_path)
680        .ok()
681        // BORROW: explicit .to_owned() to convert trimmed &str → owned String
682        .map(|s| s.trim().to_owned())
683        .filter(|s| !s.is_empty())
684}
685
686/// Returns the truly-downloaded byte count of a file's own chunked-download
687/// temp blob.
688///
689/// Looks up `{repo_dir}/blobs/<sha256>.chunked.part` — the chunked download
690/// path names its temp blob after the file's etag, which for LFS files is
691/// the content's `SHA256` (see [`crate::cache_layout::temp_blob_path`]) —
692/// so a specific file's partial is addressable without scanning the blobs
693/// directory. Returns `None` when the file has no known `sha256` (non-LFS
694/// file) or no temp blob exists on disk.
695///
696/// The chunked download preallocates the temp blob at its full size, so the
697/// blob's file length reads as `total_size` from the first byte onward.
698/// True progress lives in the `.chunked.part.state` resume sidecar's
699/// per-chunk `completed` offsets; this prefers their sum and falls back to
700/// the blob's file length when the sidecar is absent or unparseable
701/// (pre-v0.9.8 leftovers).
702fn partial_chunk_size(repo_dir: &Path, sha256: Option<&str>) -> Option<u64> {
703    let sha = sha256?;
704    let part_path = crate::cache_layout::temp_blob_path(repo_dir, sha);
705    let part_len = std::fs::metadata(part_path)
706        .ok()
707        .filter(std::fs::Metadata::is_file)
708        .map(|m| m.len())?;
709
710    let state_path = crate::cache_layout::temp_state_path(repo_dir, sha);
711    let sidecar_sum = std::fs::read_to_string(state_path)
712        .ok()
713        .and_then(|text| {
714            // BORROW: explicit .as_str() instead of Deref coercion
715            serde_json::from_str::<crate::chunked_state::ChunkedState>(text.as_str()).ok()
716        })
717        .map(|state| state.chunks.iter().map(|c| c.completed).sum::<u64>());
718
719    Some(sidecar_sum.unwrap_or(part_len))
720}
721
722/// Returns the size of the first `.chunked.part` file found in the blobs directory.
723fn find_partial_blob_size(blobs_dir: &Path) -> u64 {
724    let Ok(entries) = std::fs::read_dir(blobs_dir) else {
725        return 0;
726    };
727
728    for entry in entries {
729        let Ok(entry) = entry else { continue };
730        let name = entry.file_name();
731        // BORROW: explicit .to_string_lossy() for OsString → str conversion
732        if name.to_string_lossy().ends_with(".chunked.part") {
733            return entry.metadata().map_or(0, |m| m.len());
734        }
735    }
736
737    0
738}
739
740/// A `.chunked.part` temp file left by an interrupted chunked download.
741#[derive(Debug, Clone)]
742pub struct PartialFile {
743    /// The repository identifier (e.g., `"meta-llama/Llama-3.2-1B"`).
744    pub repo_id: String,
745    /// The `.chunked.part` filename (e.g., `"abc123def456.chunked.part"`).
746    pub filename: String,
747    /// Absolute path to the `.chunked.part` file.
748    pub path: PathBuf,
749    /// Size of the partial file in bytes.
750    pub size: u64,
751}
752
753impl PartialFile {
754    /// Returns sibling sidecar paths that should be removed alongside this
755    /// partial: the resume-state sidecar `{etag}.chunked.part.state` and
756    /// any orphan write-tmp `{etag}.chunked.part.state.tmp` left by an
757    /// interrupted atomic save.
758    ///
759    /// The paths are returned even when the underlying files do not exist
760    /// — callers (`run_cache_clean_partial`) attempt removal best-effort.
761    #[must_use]
762    pub fn sidecar_paths(&self) -> Vec<PathBuf> {
763        let Some(parent) = self.path.parent() else {
764            return Vec::new();
765        };
766        // String concat (mirrors `cache_layout::temp_state_path`'s
767        // rationale): the etag may itself contain periods, so
768        // `Path::with_extension` would truncate at the wrong boundary.
769        // BORROW: explicit .clone() for owned String → mutated copy
770        let mut state_name = self.filename.clone();
771        state_name.push_str(".state");
772        // BORROW: explicit .clone() for owned String → mutated copy
773        let mut tmp_name = self.filename.clone();
774        tmp_name.push_str(".state.tmp");
775        vec![parent.join(state_name), parent.join(tmp_name)]
776    }
777}
778
779/// Finds all `.chunked.part` temp files in the `HuggingFace` cache.
780///
781/// Walks `models--*/blobs/` directories and collects partial files.
782/// When `repo_filter` is `Some`, only the matching repo is scanned.
783///
784/// Returns an empty `Vec` if the cache directory does not exist.
785///
786/// # Errors
787///
788/// Returns [`FetchError::Io`] if the cache directory cannot be read.
789pub fn find_partial_files(repo_filter: Option<&str>) -> Result<Vec<PartialFile>, FetchError> {
790    let cache_dir = hf_cache_dir()?;
791
792    if !cache_dir.exists() {
793        return Ok(Vec::new());
794    }
795
796    let entries = std::fs::read_dir(&cache_dir).map_err(|e| FetchError::Io {
797        // BORROW: explicit .clone() for owned PathBuf
798        path: cache_dir.clone(),
799        source: e,
800    })?;
801
802    let mut partials: Vec<PartialFile> = Vec::new();
803
804    for entry in entries {
805        let Ok(entry) = entry else { continue };
806        let dir_name = entry.file_name();
807        // BORROW: explicit .to_string_lossy() for OsString → str conversion
808        let dir_str = dir_name.to_string_lossy();
809
810        let Some(repo_id) = repo_id_from_folder_name(&dir_str) else {
811            continue;
812        };
813
814        // Skip repos that don't match the filter.
815        // BORROW: explicit .as_str() instead of Deref coercion
816        if let Some(filter) = repo_filter
817            && repo_id.as_str() != filter
818        {
819            continue;
820        }
821
822        let blobs_dir = crate::cache_layout::blobs_dir(&entry.path());
823        let Ok(blob_entries) = std::fs::read_dir(&blobs_dir) else {
824            continue;
825        };
826
827        for blob_entry in blob_entries {
828            let Ok(blob_entry) = blob_entry else { continue };
829            let name = blob_entry.file_name();
830            // BORROW: explicit .to_string_lossy() for OsString → str conversion
831            let name_str = name.to_string_lossy();
832            if name_str.ends_with(".chunked.part") {
833                let size = blob_entry.metadata().map_or(0, |m| m.len());
834                partials.push(PartialFile {
835                    // BORROW: explicit .clone() for owned String
836                    repo_id: repo_id.clone(),
837                    // BORROW: explicit .to_string() for Cow<str> → owned String
838                    filename: name_str.to_string(),
839                    path: blob_entry.path(),
840                    size,
841                });
842            }
843        }
844    }
845
846    Ok(partials)
847}
848
849/// Per-file disk usage entry within a cached repository.
850#[derive(Debug, Clone)]
851pub struct CacheFileUsage {
852    /// Filename relative to the snapshot directory.
853    pub filename: String,
854    /// File size in bytes.
855    pub size: u64,
856}
857
858/// Returns per-file disk usage for a specific cached repository.
859///
860/// Walks the snapshot directories under
861/// `<cache_dir>/models--<org>--<name>/snapshots/` and collects each file's
862/// relative path and size. Results are sorted by size descending.
863///
864/// Returns an empty `Vec` if the repository is not cached.
865///
866/// # Errors
867///
868/// Returns [`FetchError::Io`] if the cache directory cannot be determined.
869pub fn cache_repo_usage(repo_id: &str) -> Result<Vec<CacheFileUsage>, FetchError> {
870    let cache_dir = hf_cache_dir()?;
871    let repo_dir = crate::cache_layout::repo_dir(&cache_dir, repo_id);
872
873    if !repo_dir.exists() {
874        return Ok(Vec::new());
875    }
876
877    let mut files = walk_repo_files(&repo_dir).files;
878    files.sort_by_key(|f| std::cmp::Reverse(f.size));
879    Ok(files)
880}
881
882/// One cached repo's on-disk file walk: every file's usage, the running
883/// total size, and the most recent modification time seen — everything
884/// both [`cache_repo_usage`] and [`cache_summary`] need, from a single pass
885/// over the snapshot tree.
886struct RepoFileWalk {
887    /// Every file's relative path + size (see [`CacheFileUsage`]).
888    files: Vec<CacheFileUsage>,
889    /// Sum of every file's size.
890    total_size: u64,
891    /// The newest modification time across every file, if any were found.
892    last_modified: Option<std::time::SystemTime>,
893}
894
895/// Walks every file (relative path, size, mtime) across `repo_dir`'s
896/// snapshot directories in one pass. Shared walking logic behind
897/// [`cache_repo_usage`] (which additionally resolves `repo_id` → `repo_dir`
898/// and sorts by size) and [`cache_summary`] (which needs `file_count` /
899/// `total_size` / `last_modified` for the summary row, plus the same file
900/// list to classify `.gguf` quant alternatives via
901/// [`crate::discover::gguf_size_range`]) — previously two separate
902/// recursive walks over the identical directory tree, one counting/summing
903/// without filenames, the other collecting filenames without mtime.
904fn walk_repo_files(repo_dir: &Path) -> RepoFileWalk {
905    let snapshots_dir = crate::cache_layout::snapshots_dir(repo_dir);
906    let Ok(snapshots) = std::fs::read_dir(snapshots_dir) else {
907        return RepoFileWalk {
908            files: Vec::new(),
909            total_size: 0,
910            last_modified: None,
911        };
912    };
913
914    let mut files: Vec<CacheFileUsage> = Vec::new();
915    let mut total_size: u64 = 0;
916    let mut last_modified: Option<std::time::SystemTime> = None;
917    for snap_entry in snapshots {
918        let Ok(snap_entry) = snap_entry else { continue };
919        let snap_path = snap_entry.path();
920        if !snap_path.is_dir() {
921            continue;
922        }
923        walk_snapshot_files(
924            &snap_path,
925            "",
926            &mut files,
927            &mut total_size,
928            &mut last_modified,
929        );
930    }
931    RepoFileWalk {
932        files,
933        total_size,
934        last_modified,
935    }
936}
937
938/// Recursively walks a snapshot directory, collecting `CacheFileUsage`
939/// entries while accumulating `total_size` and `last_modified` alongside —
940/// the single-pass counterpart to what used to be two separate recursive
941/// walks (see [`walk_repo_files`]).
942///
943/// The `prefix` parameter tracks the relative path from the snapshot root,
944/// so that files in subdirectories get paths like `"tokenizer/vocab.json"`.
945fn walk_snapshot_files(
946    dir: &Path,
947    prefix: &str,
948    files: &mut Vec<CacheFileUsage>,
949    total_size: &mut u64,
950    last_modified: &mut Option<std::time::SystemTime>,
951) {
952    let Ok(entries) = std::fs::read_dir(dir) else {
953        return;
954    };
955
956    for entry in entries {
957        let Ok(entry) = entry else { continue };
958        let path = entry.path();
959        // BORROW: explicit .to_string_lossy() for OsString → str conversion
960        let name = entry.file_name().to_string_lossy().to_string();
961
962        if path.is_dir() {
963            let child_prefix = if prefix.is_empty() {
964                name
965            } else {
966                format!("{prefix}/{name}")
967            };
968            walk_snapshot_files(&path, &child_prefix, files, total_size, last_modified);
969        } else {
970            let filename = if prefix.is_empty() {
971                name
972            } else {
973                format!("{prefix}/{name}")
974            };
975            // One `metadata()` call feeds both size and mtime — the two
976            // separate walks this replaces each paid for their own call.
977            let metadata = entry.metadata().ok();
978            let size = metadata.as_ref().map_or(0, std::fs::Metadata::len);
979            *total_size = total_size.saturating_add(size);
980            if let Some(modified) = metadata.as_ref().and_then(|m| m.modified().ok()) {
981                match *last_modified {
982                    Some(current) if modified <= current => {} // EXPLICIT: current mtime is more recent, keep it
983                    _ => *last_modified = Some(modified),
984                }
985            }
986            files.push(CacheFileUsage { filename, size });
987        }
988    }
989}
990
991/// Verification status for a single cached file.
992#[non_exhaustive]
993#[derive(Debug, Clone)]
994pub enum VerifyStatus {
995    /// Local `SHA256` matches the expected hash from `HuggingFace` LFS metadata.
996    Ok,
997    /// Local `SHA256` does not match the expected hash — the cached file is
998    /// corrupted (bit rot, interrupted write, or upstream blob changed).
999    Mismatch {
1000        /// Expected `SHA256` hex digest from `HuggingFace` LFS metadata.
1001        expected: String,
1002        /// Actual `SHA256` hex digest computed from the local file.
1003        actual: String,
1004    },
1005    /// File has no LFS metadata (small git-stored file); verification skipped.
1006    Skipped,
1007    /// File is absent from the local snapshot directory.
1008    Missing,
1009}
1010
1011/// Result of verifying a single cached file against `HuggingFace` LFS metadata.
1012#[derive(Debug, Clone)]
1013pub struct FileVerification {
1014    /// Filename within the repository.
1015    pub filename: String,
1016    /// File size in bytes — local size when the file is present, otherwise
1017    /// the expected size from the API (or `0` when neither is known).
1018    pub size: u64,
1019    /// Verification result.
1020    pub status: VerifyStatus,
1021}
1022
1023/// Streaming progress event emitted by [`verify_cache_with_progress`] so
1024/// callers can render per-file feedback during a long verification.
1025///
1026/// Events fire in this order:
1027/// 1. [`VerifyEvent::Started`] — once, after the metadata fetch completes,
1028///    before any per-file work begins. Carries the total file count and a
1029///    pre-computed maximum filename length so callers can size display
1030///    columns up-front.
1031/// 2. For each file in alphabetical order:
1032///    - [`VerifyEvent::FileStart`] — before the per-file `SHA256`
1033///      computation kicks in.
1034///    - [`VerifyEvent::FileComplete`] — when the per-file result is known,
1035///      carrying the [`VerifyStatus`] outcome.
1036#[non_exhaustive]
1037#[derive(Debug)]
1038pub enum VerifyEvent<'a> {
1039    /// Fired once at the start of the run with summary stats useful for
1040    /// laying out a streamed table or progress display.
1041    Started {
1042        /// Total number of files that will be verified.
1043        total: usize,
1044        /// Maximum filename length across the verification list.
1045        max_filename_len: usize,
1046    },
1047    /// A file is about to be verified.
1048    FileStart {
1049        /// 1-based index of this file in the verification list.
1050        index: usize,
1051        /// Total number of files in the verification list.
1052        total: usize,
1053        /// Filename within the repository.
1054        filename: &'a str,
1055        /// File size in bytes (local size when present, else expected size).
1056        size: u64,
1057        /// `true` when the file has LFS metadata (a real `SHA256` computation
1058        /// is about to run); `false` when the file is git-stored and will be
1059        /// skipped near-instantly.
1060        has_lfs: bool,
1061    },
1062    /// A file's verification has completed.
1063    FileComplete {
1064        /// 1-based index of this file in the verification list.
1065        index: usize,
1066        /// Total number of files in the verification list.
1067        total: usize,
1068        /// Filename within the repository.
1069        filename: &'a str,
1070        /// File size in bytes (matches the `size` from the corresponding
1071        /// [`VerifyEvent::FileStart`]).
1072        size: u64,
1073        /// The per-file verification result.
1074        status: &'a VerifyStatus,
1075    },
1076}
1077
1078/// Verifies `SHA256` digests of cached files against `HuggingFace` LFS metadata.
1079///
1080/// Fetches the expected hashes from the `HuggingFace` API and, for each file
1081/// that has an LFS `SHA256`, reads the local cached file and compares.
1082///
1083/// Files without LFS metadata (small git-stored files such as `config.json`)
1084/// are reported as [`VerifyStatus::Skipped`]; files absent from the snapshot
1085/// directory are reported as [`VerifyStatus::Missing`]. Both are
1086/// non-failures — only [`VerifyStatus::Mismatch`] indicates a corrupted file.
1087///
1088/// `revision` defaults to `"main"` when `None`. Requires network access for
1089/// the metadata fetch; the per-file digest computation is local-only.
1090///
1091/// For long verifications (multi-GiB safetensors files), prefer
1092/// [`verify_cache_with_progress`] so a CLI / GUI can render a spinner or
1093/// progress bar while each file is hashed.
1094///
1095/// # Errors
1096///
1097/// Returns [`FetchError::Http`] if the `HuggingFace` API request fails.
1098/// Returns [`FetchError::Io`] when a local cached file is present but
1099/// cannot be read.
1100pub async fn verify_cache(
1101    repo_id: &str,
1102    token: Option<&str>,
1103    revision: Option<&str>,
1104) -> Result<Vec<FileVerification>, FetchError> {
1105    verify_cache_with_progress(repo_id, token, revision, |_| {}).await
1106}
1107
1108/// Same as [`verify_cache`] but emits [`VerifyEvent`]s through `on_event`
1109/// so callers can render streaming progress (e.g. a spinner per file).
1110///
1111/// The callback runs on the same task as the verification — keep it short.
1112/// Use interior mutability ([`std::cell::Cell`], [`std::cell::RefCell`]) if
1113/// you need to track state across events; the closure may capture by shared
1114/// reference because the API requires only [`Fn`].
1115///
1116/// Files are processed in alphabetical order by filename so that streamed
1117/// output remains stable across runs and matches the sort order of the
1118/// returned [`Vec<FileVerification>`].
1119///
1120/// # Errors
1121///
1122/// Same error conditions as [`verify_cache`].
1123pub async fn verify_cache_with_progress<F>(
1124    repo_id: &str,
1125    token: Option<&str>,
1126    revision: Option<&str>,
1127    on_event: F,
1128) -> Result<Vec<FileVerification>, FetchError>
1129where
1130    F: Fn(VerifyEvent<'_>),
1131{
1132    let revision = revision.unwrap_or("main");
1133    let cache_dir = hf_cache_dir()?;
1134    let repo_dir = crate::cache_layout::repo_dir(&cache_dir, repo_id);
1135
1136    let commit_hash = read_ref(&repo_dir, revision);
1137
1138    let client = crate::chunked::build_client(token)?;
1139    let mut remote_files =
1140        crate::repo::list_repo_files_with_metadata(repo_id, token, Some(revision), &client).await?;
1141
1142    // Sort up-front so streamed output is stable across runs and matches the
1143    // returned `Vec<FileVerification>`'s order.
1144    remote_files.sort_by(|a, b| a.filename.cmp(&b.filename));
1145
1146    // BORROW: explicit .as_deref() for Option<String> → Option<&str>
1147    let snapshot_dir = commit_hash
1148        .as_deref()
1149        .map(|hash| crate::cache_layout::snapshot_dir(&repo_dir, hash));
1150
1151    let total = remote_files.len();
1152    let max_filename_len = remote_files
1153        .iter()
1154        .map(|f| f.filename.len())
1155        .max()
1156        .unwrap_or(0);
1157
1158    on_event(VerifyEvent::Started {
1159        total,
1160        max_filename_len,
1161    });
1162
1163    let mut results: Vec<FileVerification> = Vec::with_capacity(total);
1164
1165    for (i, remote) in remote_files.iter().enumerate() {
1166        let index = i + 1;
1167        let local_path = snapshot_dir
1168            .as_ref()
1169            // BORROW: explicit .as_str() for path construction
1170            .map(|dir| dir.join(remote.filename.as_str()));
1171
1172        let exists = local_path.as_ref().is_some_and(|p| p.exists());
1173        let local_size = local_path
1174            .as_ref()
1175            .filter(|_| exists)
1176            .and_then(|p| std::fs::metadata(p).ok().map(|m| m.len()))
1177            .unwrap_or(0);
1178        let expected_size = remote.size.unwrap_or(0);
1179        let display_size = if exists { local_size } else { expected_size };
1180
1181        let has_lfs = remote.sha256.is_some();
1182        on_event(VerifyEvent::FileStart {
1183            index,
1184            total,
1185            // BORROW: explicit .as_str() for &String → &str argument
1186            filename: remote.filename.as_str(),
1187            size: display_size,
1188            has_lfs,
1189        });
1190
1191        let status = match (remote.sha256.as_deref(), local_path.as_deref(), exists) {
1192            (None, _, _) => VerifyStatus::Skipped,
1193            (Some(_), None, _) | (Some(_), Some(_), false) => VerifyStatus::Missing,
1194            (Some(expected), Some(path), true) => {
1195                // BORROW: explicit .as_str() for &String → &str argument
1196                match crate::checksum::verify_sha256(path, remote.filename.as_str(), expected).await
1197                {
1198                    Ok(()) => VerifyStatus::Ok,
1199                    Err(FetchError::Checksum {
1200                        expected, actual, ..
1201                    }) => VerifyStatus::Mismatch { expected, actual },
1202                    Err(e) => return Err(e),
1203                }
1204            }
1205        };
1206
1207        on_event(VerifyEvent::FileComplete {
1208            index,
1209            total,
1210            // BORROW: explicit .as_str() for &String → &str argument
1211            filename: remote.filename.as_str(),
1212            size: display_size,
1213            status: &status,
1214        });
1215
1216        results.push(FileVerification {
1217            // BORROW: explicit .clone() for owned String
1218            filename: remote.filename.clone(),
1219            size: display_size,
1220            status,
1221        });
1222    }
1223
1224    Ok(results)
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229    #![allow(
1230        clippy::panic,
1231        clippy::unwrap_used,
1232        clippy::expect_used,
1233        clippy::indexing_slicing
1234    )]
1235
1236    use super::*;
1237
1238    fn sample_partial(filename: &str) -> PartialFile {
1239        PartialFile {
1240            repo_id: "org/model".to_owned(),
1241            filename: filename.to_owned(),
1242            path: PathBuf::from("/tmp/models--org--model/blobs").join(filename),
1243            size: 1024,
1244        }
1245    }
1246
1247    #[test]
1248    fn sidecar_paths_returns_state_and_state_tmp() {
1249        let p = sample_partial("abc123.chunked.part");
1250        let sidecars = p.sidecar_paths();
1251
1252        assert_eq!(sidecars.len(), 2);
1253        assert_eq!(
1254            sidecars[0],
1255            PathBuf::from("/tmp/models--org--model/blobs/abc123.chunked.part.state")
1256        );
1257        assert_eq!(
1258            sidecars[1],
1259            PathBuf::from("/tmp/models--org--model/blobs/abc123.chunked.part.state.tmp")
1260        );
1261    }
1262
1263    #[test]
1264    fn sidecar_paths_handles_etag_with_periods() {
1265        // Same period-handling rationale as `cache_layout::temp_state_path`:
1266        // the etag may itself contain dots, so naive `Path::with_extension`
1267        // would chop at the wrong boundary.
1268        let p = sample_partial("abc.def.chunked.part");
1269        let sidecars = p.sidecar_paths();
1270
1271        assert_eq!(
1272            sidecars[0],
1273            PathBuf::from("/tmp/models--org--model/blobs/abc.def.chunked.part.state")
1274        );
1275        assert_eq!(
1276            sidecars[1],
1277            PathBuf::from("/tmp/models--org--model/blobs/abc.def.chunked.part.state.tmp")
1278        );
1279    }
1280
1281    #[test]
1282    fn snapshot_roundtrip_write_then_read_returns_equal_value() {
1283        // Use a freshly-created temp dir so the test is isolated from any
1284        // pre-existing cache state on the developer machine / CI runner.
1285        let tmp =
1286            std::env::temp_dir().join(format!("hf-fm-snapshot-roundtrip-{}", std::process::id()));
1287        std::fs::create_dir_all(&tmp).expect("create temp dir");
1288
1289        let original = Snapshot {
1290            version: SNAPSHOT_VERSION,
1291            revision: "main".to_owned(),
1292            preset: Some("safetensors".to_owned()),
1293            filter: vec!["*.json".to_owned()],
1294            exclude: vec!["*.md".to_owned()],
1295        };
1296
1297        write_snapshot(&tmp, &original).expect("write_snapshot");
1298        let round_tripped = read_snapshot(&tmp)
1299            .expect("read_snapshot")
1300            .expect("snapshot present");
1301
1302        assert_eq!(round_tripped, original);
1303
1304        // Cleanup
1305        let _ = std::fs::remove_file(snapshot_path(&tmp));
1306        let _ = std::fs::remove_dir(&tmp);
1307    }
1308
1309    #[test]
1310    fn snapshot_read_returns_none_when_absent() {
1311        let tmp =
1312            std::env::temp_dir().join(format!("hf-fm-snapshot-absent-{}", std::process::id()));
1313        std::fs::create_dir_all(&tmp).expect("create temp dir");
1314
1315        let result = read_snapshot(&tmp).expect("read_snapshot");
1316        assert!(result.is_none(), "expected None for absent sidecar");
1317
1318        let _ = std::fs::remove_dir(&tmp);
1319    }
1320
1321    #[test]
1322    fn partial_chunk_size_returns_own_chunk_size() {
1323        let tmp =
1324            std::env::temp_dir().join(format!("hf-fm-partial-own-chunk-{}", std::process::id()));
1325        let blobs = tmp.join("blobs");
1326        std::fs::create_dir_all(&blobs).expect("create blobs dir");
1327        let sha = "41246eed00000000000000000000000000000000000000000000000000000344";
1328        std::fs::write(blobs.join(format!("{sha}.chunked.part")), vec![0u8; 1234])
1329            .expect("write partial blob");
1330
1331        assert_eq!(partial_chunk_size(&tmp, Some(sha)), Some(1234));
1332
1333        let _ = std::fs::remove_dir_all(&tmp);
1334    }
1335
1336    #[test]
1337    fn partial_chunk_size_ignores_other_files_chunks() {
1338        // The regression this guards: a chunk belonging to ANOTHER file must
1339        // not be attributed to this one (the pre-v0.10.5 repo-level fallback
1340        // assigned the first chunk found to every absent file).
1341        let tmp =
1342            std::env::temp_dir().join(format!("hf-fm-partial-other-chunk-{}", std::process::id()));
1343        let blobs = tmp.join("blobs");
1344        std::fs::create_dir_all(&blobs).expect("create blobs dir");
1345        let other_sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1346        std::fs::write(
1347            blobs.join(format!("{other_sha}.chunked.part")),
1348            vec![0u8; 999],
1349        )
1350        .expect("write partial blob");
1351
1352        let queried_sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
1353        assert_eq!(partial_chunk_size(&tmp, Some(queried_sha)), None);
1354
1355        let _ = std::fs::remove_dir_all(&tmp);
1356    }
1357
1358    #[test]
1359    fn partial_chunk_size_none_without_sha() {
1360        // Non-LFS files carry no sha256; the lookup must not guess.
1361        let tmp = std::env::temp_dir().join(format!("hf-fm-partial-no-sha-{}", std::process::id()));
1362        assert_eq!(partial_chunk_size(&tmp, None), None);
1363    }
1364
1365    #[test]
1366    fn partial_chunk_size_prefers_sidecar_completed_sum() {
1367        // The temp blob is preallocated at full size, so its file length is
1368        // total_size from the first byte; the truth is the sidecar's summed
1369        // per-chunk `completed` offsets.
1370        let tmp =
1371            std::env::temp_dir().join(format!("hf-fm-partial-sidecar-{}", std::process::id()));
1372        let blobs = tmp.join("blobs");
1373        std::fs::create_dir_all(&blobs).expect("create blobs dir");
1374        let sha = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
1375        // Preallocated blob: 4096 bytes on disk, only 300 truly downloaded.
1376        std::fs::write(blobs.join(format!("{sha}.chunked.part")), vec![0u8; 4096])
1377            .expect("write partial blob");
1378        let sidecar = format!(
1379            "{{\"schema_version\":1,\"etag\":\"{sha}\",\"total_size\":4096,\
1380             \"connections\":2,\"chunks\":[\
1381             {{\"idx\":0,\"start\":0,\"end\":2047,\"completed\":100}},\
1382             {{\"idx\":1,\"start\":2048,\"end\":4095,\"completed\":200}}]}}"
1383        );
1384        std::fs::write(blobs.join(format!("{sha}.chunked.part.state")), sidecar)
1385            .expect("write sidecar");
1386
1387        assert_eq!(partial_chunk_size(&tmp, Some(sha)), Some(300));
1388
1389        let _ = std::fs::remove_dir_all(&tmp);
1390    }
1391
1392    #[test]
1393    fn partial_chunk_size_falls_back_to_blob_len_on_corrupt_sidecar() {
1394        let tmp = std::env::temp_dir().join(format!(
1395            "hf-fm-partial-corrupt-sidecar-{}",
1396            std::process::id()
1397        ));
1398        let blobs = tmp.join("blobs");
1399        std::fs::create_dir_all(&blobs).expect("create blobs dir");
1400        let sha = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
1401        std::fs::write(blobs.join(format!("{sha}.chunked.part")), vec![0u8; 2048])
1402            .expect("write partial blob");
1403        std::fs::write(
1404            blobs.join(format!("{sha}.chunked.part.state")),
1405            "not json at all",
1406        )
1407        .expect("write corrupt sidecar");
1408
1409        assert_eq!(partial_chunk_size(&tmp, Some(sha)), Some(2048));
1410
1411        let _ = std::fs::remove_dir_all(&tmp);
1412    }
1413}