Skip to main content

hf_fetch_model/
cache_layout.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Centralized `hf-hub` cache path construction.
4//!
5//! All paths follow the `hf-hub` 0.5 cache layout:
6//! `{cache_root}/models--org--name/{snapshots,blobs,refs}/...`
7//!
8//! This module is the single source of truth for cache directory structure.
9//! When `hf-hub` bumps, update this module and rerun the
10//! `cache_layout_matches_hf_hub` integration test in `tests/integration.rs`.
11
12use std::path::{Path, PathBuf};
13
14use hf_hub::{Repo, RepoType};
15
16/// Repo folder name: `"models--org--name"`.
17///
18/// Delegates to [`hf_hub::Repo::folder_name()`].
19#[must_use]
20pub fn repo_folder_name(repo_id: &str) -> String {
21    // BORROW: explicit .to_owned() for &str → owned String required by Repo::new
22    Repo::new(repo_id.to_owned(), RepoType::Model).folder_name()
23}
24
25/// Repo root directory: `{cache_root}/models--org--name/`.
26#[must_use]
27pub fn repo_dir(cache_root: &Path, repo_id: &str) -> PathBuf {
28    cache_root.join(repo_folder_name(repo_id))
29}
30
31/// Snapshots directory: `{repo_dir}/snapshots/`.
32#[must_use]
33pub fn snapshots_dir(repo_dir: &Path) -> PathBuf {
34    repo_dir.join("snapshots")
35}
36
37/// Snapshot directory for a specific commit: `{repo_dir}/snapshots/{commit_hash}/`.
38#[must_use]
39pub fn snapshot_dir(repo_dir: &Path, commit_hash: &str) -> PathBuf {
40    snapshots_dir(repo_dir).join(commit_hash)
41}
42
43/// Pointer path: `{repo_dir}/snapshots/{commit_hash}/{filename}`.
44#[must_use]
45pub fn pointer_path(repo_dir: &Path, commit_hash: &str, filename: &str) -> PathBuf {
46    snapshot_dir(repo_dir, commit_hash).join(filename)
47}
48
49/// Blobs directory: `{repo_dir}/blobs/`.
50#[must_use]
51pub fn blobs_dir(repo_dir: &Path) -> PathBuf {
52    repo_dir.join("blobs")
53}
54
55/// Blob path: `{repo_dir}/blobs/{etag}`.
56#[must_use]
57pub fn blob_path(repo_dir: &Path, etag: &str) -> PathBuf {
58    blobs_dir(repo_dir).join(etag)
59}
60
61/// Temp blob path for chunked downloads: `{repo_dir}/blobs/{etag}.chunked.part`.
62///
63/// Uses string concatenation rather than [`Path::with_extension`] to handle
64/// etags containing periods (e.g., `"abc.def"` → `"abc.def.chunked.part"`,
65/// not `"abc.chunked.part"`).
66#[must_use]
67pub fn temp_blob_path(repo_dir: &Path, etag: &str) -> PathBuf {
68    // BORROW: explicit .to_owned() for &str → owned String for path concatenation
69    let mut name = etag.to_owned();
70    name.push_str(".chunked.part");
71    blobs_dir(repo_dir).join(name)
72}
73
74/// Resume-state sidecar path for chunked downloads:
75/// `{repo_dir}/blobs/{etag}.chunked.part.state`.
76///
77/// Lives next to the [`temp_blob_path`] partial and tracks per-chunk
78/// completion offsets so that an interrupted download can resume on the
79/// next invocation. Cleaned up on successful finalization, kept alongside
80/// the partial when the download is interrupted.
81///
82/// Same period-handling rationale as [`temp_blob_path`]: explicit string
83/// concatenation rather than [`Path::with_extension`] so that etags
84/// containing periods round-trip correctly.
85#[must_use]
86pub fn temp_state_path(repo_dir: &Path, etag: &str) -> PathBuf {
87    // BORROW: explicit .to_owned() for &str → owned String for path concatenation
88    let mut name = etag.to_owned();
89    name.push_str(".chunked.part.state");
90    blobs_dir(repo_dir).join(name)
91}
92
93/// Refs directory: `{repo_dir}/refs/`.
94#[must_use]
95pub fn refs_dir(repo_dir: &Path) -> PathBuf {
96    repo_dir.join("refs")
97}
98
99/// Ref file path: `{repo_dir}/refs/{revision}`.
100#[must_use]
101pub fn ref_path(repo_dir: &Path, revision: &str) -> PathBuf {
102    refs_dir(repo_dir).join(revision)
103}
104
105#[cfg(test)]
106mod tests {
107    #![allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
108
109    use super::*;
110
111    #[test]
112    fn blob_path_joins_repo_dir_and_etag() {
113        let rd = Path::new("/tmp/models--x--y");
114        assert_eq!(blob_path(rd, "abc123"), rd.join("blobs").join("abc123"));
115    }
116
117    #[test]
118    fn temp_blob_path_preserves_periods_in_etag() {
119        // Guards the specific behaviour called out in the `temp_blob_path`
120        // doc comment: etags containing periods must not be truncated by
121        // `Path::with_extension`. `"abc.def"` + `".chunked.part"` →
122        // `"abc.def.chunked.part"`, NOT `"abc.chunked.part"`.
123        let rd = Path::new("/tmp/models--x--y");
124        assert_eq!(
125            temp_blob_path(rd, "abc.def"),
126            rd.join("blobs").join("abc.def.chunked.part")
127        );
128    }
129
130    #[test]
131    fn temp_state_path_lives_next_to_temp_blob() {
132        let rd = Path::new("/tmp/models--x--y");
133        let etag = "abc123";
134        assert_eq!(
135            temp_state_path(rd, etag),
136            rd.join("blobs").join("abc123.chunked.part.state")
137        );
138        // The two helpers must agree on the directory so the sidecar
139        // always sits right next to its partial.
140        assert_eq!(
141            temp_blob_path(rd, etag).parent(),
142            temp_state_path(rd, etag).parent()
143        );
144    }
145
146    #[test]
147    fn temp_state_path_preserves_periods_in_etag() {
148        let rd = Path::new("/tmp/models--x--y");
149        assert_eq!(
150            temp_state_path(rd, "abc.def"),
151            rd.join("blobs").join("abc.def.chunked.part.state")
152        );
153    }
154}