use std::path::Path;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use crate::error::FetchError;
use crate::inspect::SafetensorsHeaderInfo;
const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeaderCacheEntry {
pub schema_version: u32,
pub repo: String,
pub revision: String,
pub filename: String,
pub etag: String,
pub cached_at: SystemTime,
pub info: SafetensorsHeaderInfo,
}
impl HeaderCacheEntry {
#[must_use]
pub fn new(
repo: String,
revision: String,
filename: String,
etag: String,
info: SafetensorsHeaderInfo,
) -> Self {
Self {
schema_version: SCHEMA_VERSION,
repo,
revision,
filename,
etag,
cached_at: SystemTime::now(),
info,
}
}
#[must_use]
pub fn is_compatible_with(
&self,
repo: &str,
revision: &str,
filename: &str,
etag: &str,
) -> bool {
self.schema_version == SCHEMA_VERSION
&& self.repo == repo
&& self.revision == revision
&& self.filename == filename
&& self.etag == etag
}
pub async fn load(
path: &Path,
repo: &str,
revision: &str,
filename: &str,
etag: &str,
) -> Option<Self> {
let text = tokio::fs::read_to_string(path).await.ok()?;
let entry: Self = serde_json::from_str(&text).ok()?;
if entry.is_compatible_with(repo, revision, filename, etag) {
Some(entry)
} else {
None
}
}
pub async fn save_atomic(&self, path: &Path) -> Result<(), FetchError> {
let json = serde_json::to_string(self).map_err(|e| {
FetchError::Http(format!("failed to serialize header-cache entry: {e}"))
})?;
let tmp = path.with_extension("json.tmp");
crate::atomic_write::write_atomic(path, &tmp, json.as_bytes()).await
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
use super::*;
use crate::inspect::TensorInfo;
fn sample_info() -> SafetensorsHeaderInfo {
SafetensorsHeaderInfo::new(
vec![TensorInfo {
name: "model.embed.weight".to_owned(),
dtype: "BF16".to_owned(),
shape: vec![100, 100],
data_offsets: (0, 20_000),
}],
None,
64,
Some(20_064),
None,
)
}
#[test]
fn is_compatible_with_matches_every_key_field() {
let entry = HeaderCacheEntry::new(
"org/model".to_owned(),
"main".to_owned(),
"model.gguf".to_owned(),
"etag-1".to_owned(),
sample_info(),
);
assert!(entry.is_compatible_with("org/model", "main", "model.gguf", "etag-1"));
assert!(!entry.is_compatible_with("org/other", "main", "model.gguf", "etag-1"));
assert!(!entry.is_compatible_with("org/model", "v2", "model.gguf", "etag-1"));
assert!(!entry.is_compatible_with("org/model", "main", "other.gguf", "etag-1"));
assert!(!entry.is_compatible_with("org/model", "main", "model.gguf", "etag-2"));
}
#[test]
fn is_compatible_with_rejects_schema_version_mismatch() {
let mut entry = HeaderCacheEntry::new(
"org/model".to_owned(),
"main".to_owned(),
"model.gguf".to_owned(),
"etag-1".to_owned(),
sample_info(),
);
entry.schema_version = SCHEMA_VERSION + 1;
assert!(!entry.is_compatible_with("org/model", "main", "model.gguf", "etag-1"));
}
#[tokio::test]
async fn save_then_load_round_trips() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(".hf-fm-header-cache").join("entry.json");
let entry = HeaderCacheEntry::new(
"org/model".to_owned(),
"main".to_owned(),
"model.gguf".to_owned(),
"etag-1".to_owned(),
sample_info(),
);
entry.save_atomic(&path).await.expect("save");
let loaded = HeaderCacheEntry::load(&path, "org/model", "main", "model.gguf", "etag-1")
.await
.expect("load should hit");
assert_eq!(loaded.repo, entry.repo);
assert_eq!(loaded.etag, entry.etag);
assert_eq!(loaded.info.tensors.len(), entry.info.tensors.len());
assert_eq!(
loaded.info.tensors.first().map(|t| t.name.as_str()),
Some("model.embed.weight")
);
}
#[tokio::test]
async fn load_returns_none_for_mismatched_etag() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(".hf-fm-header-cache").join("entry.json");
let entry = HeaderCacheEntry::new(
"org/model".to_owned(),
"main".to_owned(),
"model.gguf".to_owned(),
"etag-1".to_owned(),
sample_info(),
);
entry.save_atomic(&path).await.expect("save");
let loaded =
HeaderCacheEntry::load(&path, "org/model", "main", "model.gguf", "etag-2").await;
assert!(loaded.is_none());
}
#[tokio::test]
async fn load_returns_none_for_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(".hf-fm-header-cache").join("missing.json");
let loaded =
HeaderCacheEntry::load(&path, "org/model", "main", "model.gguf", "etag-1").await;
assert!(loaded.is_none());
}
#[tokio::test]
async fn save_atomic_does_not_leave_tmp_behind() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(".hf-fm-header-cache").join("entry.json");
let entry = HeaderCacheEntry::new(
"org/model".to_owned(),
"main".to_owned(),
"model.gguf".to_owned(),
"etag-1".to_owned(),
sample_info(),
);
entry.save_atomic(&path).await.expect("save");
assert!(!path.with_extension("json.tmp").exists());
assert!(path.exists());
}
}