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` 1.0 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
14/// Repo folder name: `"models--org--name"`.
15///
16/// Constructed here rather than delegated: `hf-hub` 0.5 exposed the naming
17/// through `Repo::folder_name()`, but the 1.0 rewrite made its equivalent
18/// (`cache::storage::repo_folder_name`) `pub(crate)`, so there is no longer
19/// an upstream function to call. The algorithm is unchanged across both
20/// versions — the repo-type plural (`"models"`) followed by the `repo_id`
21/// segments, joined by `--` — and the `cache_layout_matches_hf_hub`
22/// integration test still pins it to what `hf-hub` actually writes on disk,
23/// which is the guarantee that mattered.
24#[must_use]
25pub fn repo_folder_name(repo_id: &str) -> String {
26 let mut name = String::from("models");
27 for segment in repo_id.split('/') {
28 name.push_str("--");
29 name.push_str(segment);
30 }
31 name
32}
33
34/// Repo root directory: `{cache_root}/models--org--name/`.
35#[must_use]
36pub fn repo_dir(cache_root: &Path, repo_id: &str) -> PathBuf {
37 cache_root.join(repo_folder_name(repo_id))
38}
39
40/// Snapshots directory: `{repo_dir}/snapshots/`.
41#[must_use]
42pub fn snapshots_dir(repo_dir: &Path) -> PathBuf {
43 repo_dir.join("snapshots")
44}
45
46/// Snapshot directory for a specific commit: `{repo_dir}/snapshots/{commit_hash}/`.
47#[must_use]
48pub fn snapshot_dir(repo_dir: &Path, commit_hash: &str) -> PathBuf {
49 snapshots_dir(repo_dir).join(commit_hash)
50}
51
52/// Pointer path: `{repo_dir}/snapshots/{commit_hash}/{filename}`.
53#[must_use]
54pub fn pointer_path(repo_dir: &Path, commit_hash: &str, filename: &str) -> PathBuf {
55 snapshot_dir(repo_dir, commit_hash).join(filename)
56}
57
58/// Blobs directory: `{repo_dir}/blobs/`.
59#[must_use]
60pub fn blobs_dir(repo_dir: &Path) -> PathBuf {
61 repo_dir.join("blobs")
62}
63
64/// Blob path: `{repo_dir}/blobs/{etag}`.
65#[must_use]
66pub fn blob_path(repo_dir: &Path, etag: &str) -> PathBuf {
67 blobs_dir(repo_dir).join(etag)
68}
69
70/// Temp blob path for chunked downloads: `{repo_dir}/blobs/{etag}.chunked.part`.
71///
72/// Uses string concatenation rather than [`Path::with_extension`] to handle
73/// etags containing periods (e.g., `"abc.def"` → `"abc.def.chunked.part"`,
74/// not `"abc.chunked.part"`).
75#[must_use]
76pub fn temp_blob_path(repo_dir: &Path, etag: &str) -> PathBuf {
77 // BORROW: explicit .to_owned() for &str → owned String for path concatenation
78 let mut name = etag.to_owned();
79 name.push_str(".chunked.part");
80 blobs_dir(repo_dir).join(name)
81}
82
83/// Resume-state sidecar path for chunked downloads:
84/// `{repo_dir}/blobs/{etag}.chunked.part.state`.
85///
86/// Lives next to the [`temp_blob_path`] partial and tracks per-chunk
87/// completion offsets so that an interrupted download can resume on the
88/// next invocation. Cleaned up on successful finalization, kept alongside
89/// the partial when the download is interrupted.
90///
91/// Same period-handling rationale as [`temp_blob_path`]: explicit string
92/// concatenation rather than [`Path::with_extension`] so that etags
93/// containing periods round-trip correctly.
94#[must_use]
95pub fn temp_state_path(repo_dir: &Path, etag: &str) -> PathBuf {
96 // BORROW: explicit .to_owned() for &str → owned String for path concatenation
97 let mut name = etag.to_owned();
98 name.push_str(".chunked.part.state");
99 blobs_dir(repo_dir).join(name)
100}
101
102/// Refs directory: `{repo_dir}/refs/`.
103#[must_use]
104pub fn refs_dir(repo_dir: &Path) -> PathBuf {
105 repo_dir.join("refs")
106}
107
108/// Ref file path: `{repo_dir}/refs/{revision}`.
109#[must_use]
110pub fn ref_path(repo_dir: &Path, revision: &str) -> PathBuf {
111 refs_dir(repo_dir).join(revision)
112}
113
114/// Header-cache directory: `{repo_dir}/.hf-fm-header-cache/`.
115///
116/// An `hf-fm`-private sidecar directory, not part of the standard `hf-hub`
117/// layout — holds `inspect --cache-headers` entries, following the same
118/// precedent as the `.hf-fm-snapshot.json` sidecar in [`crate::cache`].
119#[must_use]
120pub fn header_cache_dir(repo_dir: &Path) -> PathBuf {
121 repo_dir.join(".hf-fm-header-cache")
122}
123
124/// Header-cache entry path:
125/// `{repo_dir}/.hf-fm-header-cache/{sanitized filename}.{etag}.json`.
126///
127/// `filename` may contain path separators (a nested file, e.g.
128/// `subdir/model.gguf`) — sanitized to a single path component by replacing
129/// `/` and `\` with `__`, so the cache directory never needs subdirectories
130/// of its own. Uses string concatenation rather than [`Path::with_extension`]
131/// so an etag containing periods round-trips correctly, the same rationale
132/// as [`temp_blob_path`] / [`temp_state_path`].
133#[must_use]
134pub fn header_cache_path(repo_dir: &Path, filename: &str, etag: &str) -> PathBuf {
135 let mut name = filename.replace(['/', '\\'], "__");
136 name.push('.');
137 name.push_str(etag);
138 name.push_str(".json");
139 header_cache_dir(repo_dir).join(name)
140}
141
142#[cfg(test)]
143mod tests {
144 #![allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
145
146 use super::*;
147
148 #[test]
149 fn blob_path_joins_repo_dir_and_etag() {
150 let rd = Path::new("/tmp/models--x--y");
151 assert_eq!(blob_path(rd, "abc123"), rd.join("blobs").join("abc123"));
152 }
153
154 #[test]
155 fn temp_blob_path_preserves_periods_in_etag() {
156 // Guards the specific behaviour called out in the `temp_blob_path`
157 // doc comment: etags containing periods must not be truncated by
158 // `Path::with_extension`. `"abc.def"` + `".chunked.part"` →
159 // `"abc.def.chunked.part"`, NOT `"abc.chunked.part"`.
160 let rd = Path::new("/tmp/models--x--y");
161 assert_eq!(
162 temp_blob_path(rd, "abc.def"),
163 rd.join("blobs").join("abc.def.chunked.part")
164 );
165 }
166
167 #[test]
168 fn temp_state_path_lives_next_to_temp_blob() {
169 let rd = Path::new("/tmp/models--x--y");
170 let etag = "abc123";
171 assert_eq!(
172 temp_state_path(rd, etag),
173 rd.join("blobs").join("abc123.chunked.part.state")
174 );
175 // The two helpers must agree on the directory so the sidecar
176 // always sits right next to its partial.
177 assert_eq!(
178 temp_blob_path(rd, etag).parent(),
179 temp_state_path(rd, etag).parent()
180 );
181 }
182
183 #[test]
184 fn temp_state_path_preserves_periods_in_etag() {
185 let rd = Path::new("/tmp/models--x--y");
186 assert_eq!(
187 temp_state_path(rd, "abc.def"),
188 rd.join("blobs").join("abc.def.chunked.part.state")
189 );
190 }
191
192 #[test]
193 fn header_cache_path_joins_repo_dir_filename_and_etag() {
194 let rd = Path::new("/tmp/models--x--y");
195 assert_eq!(
196 header_cache_path(rd, "model.gguf", "abc123"),
197 rd.join(".hf-fm-header-cache")
198 .join("model.gguf.abc123.json")
199 );
200 }
201
202 #[test]
203 fn header_cache_path_sanitizes_nested_filenames() {
204 let rd = Path::new("/tmp/models--x--y");
205 assert_eq!(
206 header_cache_path(rd, "subdir/model.gguf", "abc123"),
207 rd.join(".hf-fm-header-cache")
208 .join("subdir__model.gguf.abc123.json")
209 );
210 }
211
212 #[test]
213 fn header_cache_path_preserves_periods_in_etag() {
214 let rd = Path::new("/tmp/models--x--y");
215 assert_eq!(
216 header_cache_path(rd, "model.gguf", "abc.def"),
217 rd.join(".hf-fm-header-cache")
218 .join("model.gguf.abc.def.json")
219 );
220 }
221
222 #[test]
223 fn header_cache_path_lives_under_header_cache_dir() {
224 let rd = Path::new("/tmp/models--x--y");
225 assert_eq!(
226 header_cache_path(rd, "model.gguf", "abc123").parent(),
227 Some(header_cache_dir(rd).as_path())
228 );
229 }
230}