1use std::path::Path;
21use std::time::SystemTime;
22
23use serde::{Deserialize, Serialize};
24
25use crate::error::FetchError;
26use crate::inspect::SafetensorsHeaderInfo;
27
28const SCHEMA_VERSION: u32 = 1;
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct HeaderCacheEntry {
38 pub schema_version: u32,
40 pub repo: String,
42 pub revision: String,
45 pub filename: String,
47 pub etag: String,
50 pub cached_at: SystemTime,
53 pub info: SafetensorsHeaderInfo,
55}
56
57impl HeaderCacheEntry {
58 #[must_use]
60 pub fn new(
61 repo: String,
62 revision: String,
63 filename: String,
64 etag: String,
65 info: SafetensorsHeaderInfo,
66 ) -> Self {
67 Self {
68 schema_version: SCHEMA_VERSION,
69 repo,
70 revision,
71 filename,
72 etag,
73 cached_at: SystemTime::now(),
74 info,
75 }
76 }
77
78 #[must_use]
81 pub fn is_compatible_with(
82 &self,
83 repo: &str,
84 revision: &str,
85 filename: &str,
86 etag: &str,
87 ) -> bool {
88 self.schema_version == SCHEMA_VERSION
89 && self.repo == repo
90 && self.revision == revision
91 && self.filename == filename
92 && self.etag == etag
93 }
94
95 pub async fn load(
102 path: &Path,
103 repo: &str,
104 revision: &str,
105 filename: &str,
106 etag: &str,
107 ) -> Option<Self> {
108 let text = tokio::fs::read_to_string(path).await.ok()?;
109 let entry: Self = serde_json::from_str(&text).ok()?;
110 if entry.is_compatible_with(repo, revision, filename, etag) {
111 Some(entry)
112 } else {
113 None
114 }
115 }
116
117 pub async fn save_atomic(&self, path: &Path) -> Result<(), FetchError> {
129 let json = serde_json::to_string(self).map_err(|e| {
130 FetchError::Http(format!("failed to serialize header-cache entry: {e}"))
131 })?;
132 let tmp = path.with_extension("json.tmp");
133 crate::atomic_write::write_atomic(path, &tmp, json.as_bytes()).await
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 #![allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
140
141 use super::*;
142 use crate::inspect::TensorInfo;
143
144 fn sample_info() -> SafetensorsHeaderInfo {
145 SafetensorsHeaderInfo::new(
146 vec![TensorInfo {
147 name: "model.embed.weight".to_owned(),
148 dtype: "BF16".to_owned(),
149 shape: vec![100, 100],
150 data_offsets: (0, 20_000),
151 }],
152 None,
153 64,
154 Some(20_064),
155 None,
156 )
157 }
158
159 #[test]
160 fn is_compatible_with_matches_every_key_field() {
161 let entry = HeaderCacheEntry::new(
162 "org/model".to_owned(),
163 "main".to_owned(),
164 "model.gguf".to_owned(),
165 "etag-1".to_owned(),
166 sample_info(),
167 );
168 assert!(entry.is_compatible_with("org/model", "main", "model.gguf", "etag-1"));
169 assert!(!entry.is_compatible_with("org/other", "main", "model.gguf", "etag-1"));
170 assert!(!entry.is_compatible_with("org/model", "v2", "model.gguf", "etag-1"));
171 assert!(!entry.is_compatible_with("org/model", "main", "other.gguf", "etag-1"));
172 assert!(!entry.is_compatible_with("org/model", "main", "model.gguf", "etag-2"));
173 }
174
175 #[test]
176 fn is_compatible_with_rejects_schema_version_mismatch() {
177 let mut entry = HeaderCacheEntry::new(
178 "org/model".to_owned(),
179 "main".to_owned(),
180 "model.gguf".to_owned(),
181 "etag-1".to_owned(),
182 sample_info(),
183 );
184 entry.schema_version = SCHEMA_VERSION + 1;
185 assert!(!entry.is_compatible_with("org/model", "main", "model.gguf", "etag-1"));
186 }
187
188 #[tokio::test]
189 async fn save_then_load_round_trips() {
190 let dir = tempfile::tempdir().expect("tempdir");
191 let path = dir.path().join(".hf-fm-header-cache").join("entry.json");
192 let entry = HeaderCacheEntry::new(
193 "org/model".to_owned(),
194 "main".to_owned(),
195 "model.gguf".to_owned(),
196 "etag-1".to_owned(),
197 sample_info(),
198 );
199
200 entry.save_atomic(&path).await.expect("save");
201 let loaded = HeaderCacheEntry::load(&path, "org/model", "main", "model.gguf", "etag-1")
202 .await
203 .expect("load should hit");
204
205 assert_eq!(loaded.repo, entry.repo);
206 assert_eq!(loaded.etag, entry.etag);
207 assert_eq!(loaded.info.tensors.len(), entry.info.tensors.len());
208 assert_eq!(
209 loaded.info.tensors.first().map(|t| t.name.as_str()),
210 Some("model.embed.weight")
211 );
212 }
213
214 #[tokio::test]
215 async fn load_returns_none_for_mismatched_etag() {
216 let dir = tempfile::tempdir().expect("tempdir");
217 let path = dir.path().join(".hf-fm-header-cache").join("entry.json");
218 let entry = HeaderCacheEntry::new(
219 "org/model".to_owned(),
220 "main".to_owned(),
221 "model.gguf".to_owned(),
222 "etag-1".to_owned(),
223 sample_info(),
224 );
225 entry.save_atomic(&path).await.expect("save");
226
227 let loaded =
228 HeaderCacheEntry::load(&path, "org/model", "main", "model.gguf", "etag-2").await;
229 assert!(loaded.is_none());
230 }
231
232 #[tokio::test]
233 async fn load_returns_none_for_missing_file() {
234 let dir = tempfile::tempdir().expect("tempdir");
235 let path = dir.path().join(".hf-fm-header-cache").join("missing.json");
236 let loaded =
237 HeaderCacheEntry::load(&path, "org/model", "main", "model.gguf", "etag-1").await;
238 assert!(loaded.is_none());
239 }
240
241 #[tokio::test]
242 async fn save_atomic_does_not_leave_tmp_behind() {
243 let dir = tempfile::tempdir().expect("tempdir");
244 let path = dir.path().join(".hf-fm-header-cache").join("entry.json");
245 let entry = HeaderCacheEntry::new(
246 "org/model".to_owned(),
247 "main".to_owned(),
248 "model.gguf".to_owned(),
249 "etag-1".to_owned(),
250 sample_info(),
251 );
252 entry.save_atomic(&path).await.expect("save");
253 assert!(!path.with_extension("json.tmp").exists());
254 assert!(path.exists());
255 }
256}