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}