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}