use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::Write;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use super::quant_select::QuantType;
use crate::core::sha256::sha256_file;
use crate::core::provenance::SourceShard;
pub const MANIFEST_SCHEMA_VERSION: u32 = 2;
pub const MANIFEST_SCHEMA_MIN_SUPPORTED: u32 = 1;
pub const HF2Q_CACHE_DIR_ENV: &str = "HF2Q_CACHE_DIR";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CacheManifest {
pub schema_version: u32,
pub models: BTreeMap<String, ModelEntry>,
}
impl Default for CacheManifest {
fn default() -> Self {
Self {
schema_version: MANIFEST_SCHEMA_VERSION,
models: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ModelEntry {
pub repo_id: String,
pub revision: String,
pub source: Option<SourcePointer>,
pub quantizations: BTreeMap<String, QuantEntry>,
pub last_accessed_secs: u64,
#[serde(default)]
pub source_shards: Vec<SourceShard>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SourcePointer {
HfHub { path: PathBuf, revision: String },
Local { path: PathBuf, sha256: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct QuantEntry {
pub quant_type: String,
pub gguf_path: PathBuf,
pub mmproj_path: Option<PathBuf>,
pub bytes: u64,
pub sha256: String,
pub quantized_at_secs: u64,
pub quantized_by_version: String,
}
pub fn default_root() -> Result<PathBuf> {
if let Ok(v) = std::env::var(HF2Q_CACHE_DIR_ENV) {
if !v.is_empty() {
return Ok(PathBuf::from(v));
}
}
if let Ok(v) = std::env::var("XDG_CACHE_HOME") {
if !v.is_empty() {
return Ok(PathBuf::from(v).join("hf2q"));
}
}
if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() {
return Ok(PathBuf::from(home).join(".cache").join("hf2q"));
}
}
Err(anyhow!(
"cannot resolve cache root: HF2Q_CACHE_DIR, XDG_CACHE_HOME, and HOME \
are all unset (set HF2Q_CACHE_DIR explicitly to override)"
))
}
pub fn slug_repo_id(repo_id: &str) -> Result<String> {
if repo_id.is_empty() {
return Err(anyhow!("repo_id is empty"));
}
if repo_id.contains('\0') {
return Err(anyhow!("repo_id contains NUL byte: {:?}", repo_id));
}
if repo_id.contains("..") {
return Err(anyhow!(
"repo_id contains '..' (path-traversal guard): {}",
repo_id
));
}
Ok(repo_id.replace('/', "__"))
}
pub fn unslug_repo_id(slug: &str) -> String {
slug.replace("__", "/")
}
pub fn cache_model_path(root: &Path, repo_id: &str, quant: QuantType) -> Result<PathBuf> {
let slug = slug_repo_id(repo_id)?;
Ok(root
.join("models")
.join(slug)
.join("quantized")
.join(quant.as_str())
.join("model.gguf"))
}
pub fn cache_mmproj_path(root: &Path, repo_id: &str, quant: QuantType) -> Result<PathBuf> {
let slug = slug_repo_id(repo_id)?;
Ok(root
.join("models")
.join(slug)
.join("quantized")
.join(quant.as_str())
.join("mmproj.gguf"))
}
pub struct CacheLock {
file: Option<File>,
}
impl CacheLock {
pub fn acquire(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create lock dir: {}", parent.display()))?;
}
let file = File::options()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)
.with_context(|| format!("open lock file: {}", path.display()))?;
let fd = file.as_raw_fd();
let ret = unsafe { libc::flock(fd, libc::LOCK_EX) };
if ret != 0 {
return Err(std::io::Error::last_os_error())
.with_context(|| format!("flock LOCK_EX: {}", path.display()));
}
Ok(Self { file: Some(file) })
}
pub fn try_acquire(path: &Path) -> Result<Option<Self>> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create lock dir: {}", parent.display()))?;
}
let file = File::options()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)
.with_context(|| format!("open lock file: {}", path.display()))?;
let fd = file.as_raw_fd();
let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if ret == 0 {
Ok(Some(Self { file: Some(file) }))
} else {
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
Ok(None)
} else {
Err(err).with_context(|| format!("flock LOCK_EX|LOCK_NB: {}", path.display()))
}
}
}
}
impl Drop for CacheLock {
fn drop(&mut self) {
if let Some(file) = self.file.take() {
let fd = file.as_raw_fd();
unsafe {
libc::flock(fd, libc::LOCK_UN);
}
}
}
}
#[derive(Debug)]
pub struct ModelCache {
root: PathBuf,
manifest: CacheManifest,
}
impl ModelCache {
pub fn open() -> Result<Self> {
Self::open_at(default_root()?)
}
pub fn open_at(root: impl AsRef<Path>) -> Result<Self> {
let root = root.as_ref().to_path_buf();
ensure_layout(&root)?;
let manifest_path = root.join("manifest.json");
let manifest = if manifest_path.exists() {
read_manifest(&manifest_path)?
} else {
CacheManifest::default()
};
Ok(Self { root, manifest })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn manifest(&self) -> &CacheManifest {
&self.manifest
}
pub fn lookup(&self, repo_id: &str, quant: QuantType) -> Option<&QuantEntry> {
self.manifest
.models
.get(repo_id)
.and_then(|m| m.quantizations.get(quant.as_str()))
}
pub fn lookup_model(&self, repo_id: &str) -> Option<&ModelEntry> {
self.manifest.models.get(repo_id)
}
pub fn record_source(
&mut self,
repo_id: &str,
revision: &str,
source: SourcePointer,
) -> Result<()> {
let _ = slug_repo_id(repo_id)?;
let now = secs_since_epoch();
let entry = self
.manifest
.models
.entry(repo_id.to_string())
.or_insert_with(|| ModelEntry {
repo_id: repo_id.to_string(),
revision: revision.to_string(),
source: None,
quantizations: BTreeMap::new(),
last_accessed_secs: now,
source_shards: Vec::new(),
});
entry.revision = revision.to_string();
entry.source = Some(source);
entry.last_accessed_secs = now;
let slug = slug_repo_id(repo_id)?;
let model_dir = self.root.join("models").join(&slug);
fs::create_dir_all(&model_dir)
.with_context(|| format!("create model dir: {}", model_dir.display()))?;
let meta_path = model_dir.join("repo_meta.json");
let entry_clone = entry.clone();
write_json_atomic(&meta_path, &entry_clone)?;
self.flush()
}
pub fn record_quantized(&mut self, repo_id: &str, entry: QuantEntry) -> Result<()> {
let _ = slug_repo_id(repo_id)?;
let model = self
.manifest
.models
.get_mut(repo_id)
.ok_or_else(|| anyhow!("record_quantized: no source recorded for {}", repo_id))?;
model
.quantizations
.insert(entry.quant_type.clone(), entry.clone());
model.last_accessed_secs = secs_since_epoch();
let quant_dir = entry
.gguf_path
.parent()
.ok_or_else(|| anyhow!("gguf_path has no parent: {}", entry.gguf_path.display()))?
.to_path_buf();
fs::create_dir_all(&quant_dir)
.with_context(|| format!("create quant dir: {}", quant_dir.display()))?;
let companion = quant_dir.join("manifest.json");
write_json_atomic(&companion, &entry)?;
self.flush()
}
pub fn record_source_with_shards(
&mut self,
repo_id: &str,
revision: &str,
source: SourcePointer,
shards: Vec<crate::core::integrity::ShardIntegrity>,
) -> Result<()> {
let _ = slug_repo_id(repo_id)?;
let now = secs_since_epoch();
let entry = self
.manifest
.models
.entry(repo_id.to_string())
.or_insert_with(|| ModelEntry {
repo_id: repo_id.to_string(),
revision: revision.to_string(),
source: None,
quantizations: BTreeMap::new(),
last_accessed_secs: now,
source_shards: Vec::new(),
});
entry.revision = revision.to_string();
entry.source = Some(source);
entry.last_accessed_secs = now;
entry.source_shards = shards.iter().map(SourceShard::from_integrity).collect();
let slug = slug_repo_id(repo_id)?;
let model_dir = self.root.join("models").join(&slug);
fs::create_dir_all(&model_dir)
.with_context(|| format!("create model dir: {}", model_dir.display()))?;
let meta_path = model_dir.join("repo_meta.json");
let entry_clone = entry.clone();
write_json_atomic(&meta_path, &entry_clone)?;
self.flush()
}
pub fn verify_quantized(&self, repo_id: &str, quant: QuantType) -> Result<()> {
let entry = self.lookup(repo_id, quant).ok_or_else(|| {
anyhow!(
"verify_quantized: no manifest entry for {}@{}",
repo_id,
quant.as_str()
)
})?;
if !entry.gguf_path.exists() {
return Err(anyhow!(
"verify_quantized: cached GGUF missing on disk: {}",
entry.gguf_path.display()
));
}
let actual = sha256_file(&entry.gguf_path)?;
if !actual.eq_ignore_ascii_case(&entry.sha256) {
return Err(anyhow!(
"verify_quantized: SHA-256 mismatch for {}@{} at {}: \
manifest says {}, on-disk computes {}. \
The cached GGUF is corrupted; remove it (rm {}) and \
re-quantize, or pass --no-integrity to skip the check \
(NOT recommended).",
repo_id,
quant.as_str(),
entry.gguf_path.display(),
entry.sha256,
actual,
entry.gguf_path.display(),
));
}
Ok(())
}
pub fn touch(&mut self, repo_id: &str) -> Result<()> {
if let Some(m) = self.manifest.models.get_mut(repo_id) {
m.last_accessed_secs = secs_since_epoch();
self.flush()?;
}
Ok(())
}
pub fn invalidate(&mut self, repo_id: &str, quant: QuantType) -> Result<u64> {
let _lock = self.lock_quant(repo_id, quant)?;
let model = self.manifest.models.get(repo_id).ok_or_else(|| {
anyhow!(
"invalidate: unknown_repo: no manifest entry for {}",
repo_id
)
})?;
if !model.quantizations.contains_key(quant.as_str()) {
return Err(anyhow!(
"invalidate: unknown_quant: {} has no {} quantization cached",
repo_id,
quant.as_str()
));
}
let slug = slug_repo_id(repo_id)?;
let quant_dir = self
.root
.join("models")
.join(&slug)
.join("quantized")
.join(quant.as_str());
let freed = dir_total_bytes(&quant_dir);
if quant_dir.exists() {
fs::remove_dir_all(&quant_dir)
.with_context(|| format!("remove quant dir: {}", quant_dir.display()))?;
}
if let Some(m) = self.manifest.models.get_mut(repo_id) {
m.quantizations.remove(quant.as_str());
}
self.flush()?;
Ok(freed)
}
pub fn invalidate_repo(&mut self, repo_id: &str) -> Result<u64> {
let model = self.manifest.models.get(repo_id).ok_or_else(|| {
anyhow!(
"invalidate_repo: unknown_repo: no manifest entry for {}",
repo_id
)
})?;
let quant_strs: Vec<String> = model.quantizations.keys().cloned().collect();
let mut locks: Vec<CacheLock> = Vec::with_capacity(quant_strs.len());
for q in &quant_strs {
let lock_path =
self.root
.join("locks")
.join(format!("{}__{}.lock", slug_repo_id(repo_id)?, q));
locks.push(CacheLock::acquire(&lock_path)?);
}
let slug = slug_repo_id(repo_id)?;
let model_dir = self.root.join("models").join(&slug);
let freed = dir_total_bytes(&model_dir);
if model_dir.exists() {
fs::remove_dir_all(&model_dir)
.with_context(|| format!("remove model dir: {}", model_dir.display()))?;
}
self.manifest.models.remove(repo_id);
self.flush()?;
drop(locks);
Ok(freed)
}
pub fn purge(&mut self) -> Result<u64> {
let models_dir = self.root.join("models");
let freed = dir_total_bytes(&models_dir);
if models_dir.exists() {
fs::remove_dir_all(&models_dir)
.with_context(|| format!("remove models tree: {}", models_dir.display()))?;
}
fs::create_dir_all(&models_dir)
.with_context(|| format!("recreate models tree: {}", models_dir.display()))?;
self.manifest = CacheManifest::default();
self.flush()?;
Ok(freed)
}
pub fn iter_entries(&self) -> impl Iterator<Item = CacheEntryView<'_>> {
self.manifest
.models
.iter()
.map(|(repo_id, model)| CacheEntryView { repo_id, model })
}
pub fn total_bytes_on_disk(&self) -> u64 {
let models_dir = self.root.join("models");
dir_total_bytes(&models_dir)
}
pub fn flush(&self) -> Result<()> {
let path = self.root.join("manifest.json");
write_json_atomic(&path, &self.manifest)
}
pub fn lock_quant(&self, repo_id: &str, quant: QuantType) -> Result<CacheLock> {
let path = self.lock_path(repo_id, quant)?;
CacheLock::acquire(&path)
}
pub fn try_lock_quant(&self, repo_id: &str, quant: QuantType) -> Result<Option<CacheLock>> {
let path = self.lock_path(repo_id, quant)?;
CacheLock::try_acquire(&path)
}
fn lock_path(&self, repo_id: &str, quant: QuantType) -> Result<PathBuf> {
let slug = slug_repo_id(repo_id)?;
Ok(self
.root
.join("locks")
.join(format!("{slug}__{}.lock", quant.as_str())))
}
pub fn detect_hf_hub_source(repo_id: &str) -> Option<HfHubSnapshot> {
let hub_root = resolve_hf_hub_root()?;
let dir_name = format!("models--{}", repo_id.replace('/', "--"));
let model_root = hub_root.join(&dir_name);
let snapshots = model_root.join("snapshots");
if !snapshots.is_dir() {
return None;
}
let mut best: Option<(SystemTime, PathBuf, String)> = None;
for entry in fs::read_dir(&snapshots).ok()? {
let entry = entry.ok()?;
let ty = entry.file_type().ok()?;
if !ty.is_dir() {
continue;
}
let modified = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let revision = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
if best.as_ref().map(|(t, _, _)| modified > *t).unwrap_or(true) {
best = Some((modified, path, revision));
}
}
best.map(|(_, path, revision)| HfHubSnapshot { path, revision })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HfHubSnapshot {
pub path: PathBuf,
pub revision: String,
}
#[derive(Debug, Clone, Copy)]
pub struct CacheEntryView<'a> {
pub repo_id: &'a str,
pub model: &'a ModelEntry,
}
fn ensure_layout(root: &Path) -> Result<()> {
fs::create_dir_all(root.join("models"))
.with_context(|| format!("create models dir under {}", root.display()))?;
fs::create_dir_all(root.join("locks"))
.with_context(|| format!("create locks dir under {}", root.display()))?;
Ok(())
}
fn read_manifest(path: &Path) -> Result<CacheManifest> {
let text =
fs::read_to_string(path).with_context(|| format!("read manifest: {}", path.display()))?;
let mut m: CacheManifest = serde_json::from_str(&text)
.with_context(|| format!("parse manifest JSON: {}", path.display()))?;
if m.schema_version < MANIFEST_SCHEMA_MIN_SUPPORTED
|| m.schema_version > MANIFEST_SCHEMA_VERSION
{
return Err(anyhow!(
"manifest schema_version mismatch at {}: expected {}..={}, got {}",
path.display(),
MANIFEST_SCHEMA_MIN_SUPPORTED,
MANIFEST_SCHEMA_VERSION,
m.schema_version
));
}
if m.schema_version < MANIFEST_SCHEMA_VERSION {
m.schema_version = MANIFEST_SCHEMA_VERSION;
}
Ok(m)
}
fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create parent dir: {}", parent.display()))?;
}
let json = serde_json::to_string_pretty(value)
.with_context(|| format!("serialize JSON for {}", path.display()))?;
let tmp_name = format!(
".{}.tmp.{}",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("manifest.json"),
std::process::id()
);
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let tmp_path = parent.join(tmp_name);
{
let mut f = File::create(&tmp_path)
.with_context(|| format!("create temp: {}", tmp_path.display()))?;
f.write_all(json.as_bytes())
.with_context(|| format!("write temp: {}", tmp_path.display()))?;
f.sync_all()
.with_context(|| format!("fsync temp: {}", tmp_path.display()))?;
}
fs::rename(&tmp_path, path)
.with_context(|| format!("rename {} → {}", tmp_path.display(), path.display()))?;
Ok(())
}
fn secs_since_epoch() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn resolve_hf_hub_root() -> Option<PathBuf> {
if let Ok(v) = std::env::var("HF_HUB_CACHE") {
if !v.is_empty() {
return Some(PathBuf::from(v));
}
}
if let Ok(v) = std::env::var("HF_HOME") {
if !v.is_empty() {
return Some(PathBuf::from(v).join("hub"));
}
}
if let Ok(v) = std::env::var("XDG_CACHE_HOME") {
if !v.is_empty() {
return Some(PathBuf::from(v).join("huggingface").join("hub"));
}
}
if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() {
return Some(
PathBuf::from(home)
.join(".cache")
.join("huggingface")
.join("hub"),
);
}
}
None
}
fn dir_total_bytes(dir: &Path) -> u64 {
let mut total: u64 = 0;
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return 0,
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let ty = match entry.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if ty.is_symlink() {
continue;
}
if ty.is_file() {
if let Ok(meta) = entry.metadata() {
total = total.saturating_add(meta.len());
}
} else if ty.is_dir() {
total = total.saturating_add(dir_total_bytes(&entry.path()));
}
}
total
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::sha256::compute_file_sha256;
use sha2::{Digest, Sha256};
use std::sync::Mutex;
use tempfile::TempDir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
snapshots: Vec<(String, Option<String>)>,
}
impl EnvGuard {
fn new(keys: &[&str]) -> Self {
let snapshots = keys
.iter()
.map(|k| (k.to_string(), std::env::var(k).ok()))
.collect();
for k in keys {
std::env::remove_var(k);
}
Self { snapshots }
}
fn set(&self, k: &str, v: &str) {
std::env::set_var(k, v);
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (k, v) in &self.snapshots {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
}
#[test]
fn slug_simple_repo() {
assert_eq!(
slug_repo_id("google/gemma-4-27b-it").unwrap(),
"google__gemma-4-27b-it"
);
}
#[test]
fn slug_unslug_roundtrip() {
let id = "mistralai/Mistral-7B-Instruct-v0.3";
let slug = slug_repo_id(id).unwrap();
assert_eq!(slug, "mistralai__Mistral-7B-Instruct-v0.3");
assert_eq!(unslug_repo_id(&slug), id);
}
#[test]
fn slug_rejects_empty() {
assert!(slug_repo_id("").is_err());
}
#[test]
fn slug_rejects_path_traversal() {
assert!(slug_repo_id("..").is_err());
assert!(slug_repo_id("foo/../bar").is_err());
assert!(slug_repo_id("../etc/passwd").is_err());
}
#[test]
fn slug_rejects_nul() {
assert!(slug_repo_id("foo\0bar").is_err());
}
#[test]
fn slug_long_repo_id() {
let long = format!("{}/{}", "a".repeat(100), "b".repeat(100));
let slug = slug_repo_id(&long).unwrap();
assert_eq!(slug.len(), 100 + 2 + 100); assert_eq!(unslug_repo_id(&slug), long);
}
#[test]
fn slug_unicode_repo_id() {
let id = "用户/模型-v1";
let slug = slug_repo_id(id).unwrap();
assert!(slug.contains("__"));
assert_eq!(unslug_repo_id(&slug), id);
}
#[test]
fn cache_model_path_layout() {
let root = Path::new("/tmp/hf2q");
let p = cache_model_path(root, "google/gemma-4-27b-it", QuantType::Q4_K_M).unwrap();
assert_eq!(
p,
Path::new("/tmp/hf2q/models/google__gemma-4-27b-it/quantized/Q4_K_M/model.gguf")
);
}
#[test]
fn cache_mmproj_path_layout() {
let root = Path::new("/tmp/hf2q");
let p = cache_mmproj_path(root, "google/gemma-4-27b-it", QuantType::Q8_0).unwrap();
assert_eq!(
p,
Path::new("/tmp/hf2q/models/google__gemma-4-27b-it/quantized/Q8_0/mmproj.gguf")
);
}
#[test]
fn cache_model_path_idempotent() {
let root = Path::new("/tmp/hf2q");
let a = cache_model_path(root, "x/y", QuantType::Q6_K).unwrap();
let b = cache_model_path(root, "x/y", QuantType::Q6_K).unwrap();
assert_eq!(a, b);
}
#[test]
fn cache_model_path_different_quants_differ() {
let root = Path::new("/tmp/hf2q");
let q4 = cache_model_path(root, "x/y", QuantType::Q4_K_M).unwrap();
let q8 = cache_model_path(root, "x/y", QuantType::Q8_0).unwrap();
assert_ne!(q4, q8);
}
#[test]
fn default_root_honors_hf2q_cache_dir() {
let _g = ENV_LOCK.lock().unwrap();
let env = EnvGuard::new(&["HF2Q_CACHE_DIR", "XDG_CACHE_HOME", "HOME"]);
env.set("HF2Q_CACHE_DIR", "/explicit/override");
env.set("HOME", "/should/be/ignored");
env.set("XDG_CACHE_HOME", "/also/ignored");
assert_eq!(default_root().unwrap(), PathBuf::from("/explicit/override"));
}
#[test]
fn default_root_honors_xdg_cache_home() {
let _g = ENV_LOCK.lock().unwrap();
let env = EnvGuard::new(&["HF2Q_CACHE_DIR", "XDG_CACHE_HOME", "HOME"]);
env.set("XDG_CACHE_HOME", "/custom/xdg");
env.set("HOME", "/should/be/ignored");
assert_eq!(default_root().unwrap(), PathBuf::from("/custom/xdg/hf2q"));
}
#[test]
fn default_root_falls_back_to_home() {
let _g = ENV_LOCK.lock().unwrap();
let env = EnvGuard::new(&["HF2Q_CACHE_DIR", "XDG_CACHE_HOME", "HOME"]);
env.set("HOME", "/users/robert");
assert_eq!(
default_root().unwrap(),
PathBuf::from("/users/robert/.cache/hf2q")
);
}
#[test]
fn default_root_errors_when_all_env_unset() {
let _g = ENV_LOCK.lock().unwrap();
let _env = EnvGuard::new(&["HF2Q_CACHE_DIR", "XDG_CACHE_HOME", "HOME"]);
assert!(default_root().is_err());
}
#[test]
fn default_root_ignores_empty_env_values() {
let _g = ENV_LOCK.lock().unwrap();
let env = EnvGuard::new(&["HF2Q_CACHE_DIR", "XDG_CACHE_HOME", "HOME"]);
env.set("HF2Q_CACHE_DIR", "");
env.set("XDG_CACHE_HOME", "");
env.set("HOME", "/users/robert");
assert_eq!(
default_root().unwrap(),
PathBuf::from("/users/robert/.cache/hf2q")
);
}
#[test]
fn open_creates_directory_layout() {
let tmp = TempDir::new().unwrap();
let cache = ModelCache::open_at(tmp.path()).unwrap();
assert_eq!(cache.root(), tmp.path());
assert!(tmp.path().join("models").is_dir());
assert!(tmp.path().join("locks").is_dir());
assert!(!tmp.path().join("manifest.json").exists());
}
#[test]
fn lookup_returns_none_for_missing_model() {
let tmp = TempDir::new().unwrap();
let cache = ModelCache::open_at(tmp.path()).unwrap();
assert!(cache.lookup("foo/bar", QuantType::Q4_K_M).is_none());
assert!(cache.lookup_model("foo/bar").is_none());
}
#[test]
fn record_source_then_lookup() {
let tmp = TempDir::new().unwrap();
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
cache
.record_source(
"google/gemma-4-27b-it",
"abc123def",
SourcePointer::HfHub {
path: PathBuf::from("/some/hub/snapshot"),
revision: "abc123def".into(),
},
)
.unwrap();
let m = cache.lookup_model("google/gemma-4-27b-it").unwrap();
assert_eq!(m.repo_id, "google/gemma-4-27b-it");
assert_eq!(m.revision, "abc123def");
assert!(matches!(m.source, Some(SourcePointer::HfHub { .. })));
assert!(cache
.lookup("google/gemma-4-27b-it", QuantType::Q4_K_M)
.is_none());
assert!(tmp
.path()
.join("models")
.join("google__gemma-4-27b-it")
.join("repo_meta.json")
.is_file());
}
#[test]
fn record_quantized_then_lookup() {
let tmp = TempDir::new().unwrap();
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
cache
.record_source(
"x/y",
"rev1",
SourcePointer::Local {
path: PathBuf::from("/local/path"),
sha256: "0".repeat(64),
},
)
.unwrap();
let gguf = cache_model_path(tmp.path(), "x/y", QuantType::Q4_K_M).unwrap();
fs::create_dir_all(gguf.parent().unwrap()).unwrap();
fs::write(&gguf, b"GGUF\0placeholder").unwrap();
let entry = QuantEntry {
quant_type: QuantType::Q4_K_M.as_str().to_string(),
gguf_path: gguf.clone(),
mmproj_path: None,
bytes: 16,
sha256: sha256_file(&gguf).unwrap(),
quantized_at_secs: secs_since_epoch(),
quantized_by_version: env!("CARGO_PKG_VERSION").to_string(),
};
cache.record_quantized("x/y", entry.clone()).unwrap();
let got = cache.lookup("x/y", QuantType::Q4_K_M).unwrap();
assert_eq!(got, &entry);
let companion = gguf.parent().unwrap().join("manifest.json");
assert!(companion.is_file());
}
#[test]
fn record_quantized_without_source_errors() {
let tmp = TempDir::new().unwrap();
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
let gguf = cache_model_path(tmp.path(), "x/y", QuantType::Q4_K_M).unwrap();
fs::create_dir_all(gguf.parent().unwrap()).unwrap();
fs::write(&gguf, b"GGUF").unwrap();
let entry = QuantEntry {
quant_type: QuantType::Q4_K_M.as_str().to_string(),
gguf_path: gguf,
mmproj_path: None,
bytes: 4,
sha256: "0".repeat(64),
quantized_at_secs: 0,
quantized_by_version: "0.1.0".into(),
};
assert!(cache.record_quantized("x/y", entry).is_err());
}
#[test]
fn manifest_survives_reopen() {
let tmp = TempDir::new().unwrap();
{
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
cache
.record_source(
"x/y",
"rev",
SourcePointer::Local {
path: PathBuf::from("/p"),
sha256: "1".repeat(64),
},
)
.unwrap();
}
let cache = ModelCache::open_at(tmp.path()).unwrap();
let m = cache.lookup_model("x/y").unwrap();
assert_eq!(m.revision, "rev");
assert_eq!(cache.manifest().schema_version, MANIFEST_SCHEMA_VERSION);
}
#[test]
fn touch_updates_last_accessed() {
let tmp = TempDir::new().unwrap();
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
cache
.record_source(
"x/y",
"rev",
SourcePointer::Local {
path: PathBuf::from("/p"),
sha256: "2".repeat(64),
},
)
.unwrap();
let before = cache.lookup_model("x/y").unwrap().last_accessed_secs;
std::thread::sleep(std::time::Duration::from_millis(1100));
cache.touch("x/y").unwrap();
let after = cache.lookup_model("x/y").unwrap().last_accessed_secs;
assert!(after >= before, "touch must not move clock backwards");
}
#[test]
fn touch_unknown_model_is_noop() {
let tmp = TempDir::new().unwrap();
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
cache.touch("nonexistent/repo").unwrap();
assert!(!tmp.path().join("manifest.json").exists());
}
#[test]
fn atomic_write_no_temp_file_left_behind() {
let tmp = TempDir::new().unwrap();
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
cache
.record_source(
"x/y",
"rev",
SourcePointer::Local {
path: PathBuf::from("/p"),
sha256: "3".repeat(64),
},
)
.unwrap();
let entries: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(
entries.iter().any(|n| n == "manifest.json"),
"manifest.json missing in {:?}",
entries
);
assert!(
!entries.iter().any(|n| n.starts_with(".manifest.json.tmp")),
"temp file leaked: {:?}",
entries
);
}
#[test]
fn atomic_write_replaces_prior_manifest() {
let tmp = TempDir::new().unwrap();
{
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
cache
.record_source(
"a/b",
"rev1",
SourcePointer::Local {
path: PathBuf::from("/p1"),
sha256: "4".repeat(64),
},
)
.unwrap();
}
let v1 = fs::read_to_string(tmp.path().join("manifest.json")).unwrap();
{
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
cache
.record_source(
"a/b",
"rev2",
SourcePointer::Local {
path: PathBuf::from("/p2"),
sha256: "5".repeat(64),
},
)
.unwrap();
}
let v2 = fs::read_to_string(tmp.path().join("manifest.json")).unwrap();
assert_ne!(v1, v2);
assert!(v2.contains("rev2"));
assert!(!v2.contains("rev1"));
}
#[test]
fn manifest_schema_version_mismatch_errors() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path()).unwrap();
fs::write(
tmp.path().join("manifest.json"),
r#"{"schema_version": 99, "models": {}}"#,
)
.unwrap();
let err = ModelCache::open_at(tmp.path()).unwrap_err();
assert!(format!("{err}").contains("schema_version"));
}
#[test]
fn lock_acquire_releases_on_drop() {
let tmp = TempDir::new().unwrap();
let cache = ModelCache::open_at(tmp.path()).unwrap();
{
let _l = cache.lock_quant("x/y", QuantType::Q4_K_M).unwrap();
let attempt = cache.try_lock_quant("x/y", QuantType::Q4_K_M).unwrap();
assert!(
attempt.is_none(),
"concurrent try_lock_quant must observe held lock"
);
}
let again = cache.try_lock_quant("x/y", QuantType::Q4_K_M).unwrap();
assert!(again.is_some(), "lock must release on Drop");
}
#[test]
fn lock_path_per_quant_distinct() {
let tmp = TempDir::new().unwrap();
let cache = ModelCache::open_at(tmp.path()).unwrap();
let _l4 = cache.lock_quant("x/y", QuantType::Q4_K_M).unwrap();
let l8 = cache.try_lock_quant("x/y", QuantType::Q8_0).unwrap();
assert!(l8.is_some(), "different quants must not share a lock");
}
#[test]
fn lock_creates_locks_dir() {
let tmp = TempDir::new().unwrap();
let cache = ModelCache::open_at(tmp.path()).unwrap();
let _l = cache.lock_quant("x/y", QuantType::Q4_K_M).unwrap();
let lock_file = tmp
.path()
.join("locks")
.join(format!("x__y__{}.lock", QuantType::Q4_K_M.as_str()));
assert!(lock_file.is_file(), "lock file must exist on disk");
}
#[test]
fn detect_hf_hub_finds_snapshot() {
let _g = ENV_LOCK.lock().unwrap();
let tmp = TempDir::new().unwrap();
let env = EnvGuard::new(&["HF_HUB_CACHE", "HF_HOME", "XDG_CACHE_HOME", "HOME"]);
env.set("HF_HUB_CACHE", tmp.path().to_str().unwrap());
let model_dir = tmp.path().join("models--org--model");
let snap_a = model_dir.join("snapshots").join("rev-a");
let snap_b = model_dir.join("snapshots").join("rev-b");
fs::create_dir_all(&snap_a).unwrap();
fs::create_dir_all(&snap_b).unwrap();
fs::write(snap_a.join("config.json"), "{}").unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
fs::write(snap_b.join("config.json"), "{}").unwrap();
let snap = ModelCache::detect_hf_hub_source("org/model").unwrap();
assert!(snap.path.starts_with(tmp.path()));
assert!(snap.revision == "rev-a" || snap.revision == "rev-b");
}
#[test]
fn detect_hf_hub_returns_none_when_absent() {
let _g = ENV_LOCK.lock().unwrap();
let tmp = TempDir::new().unwrap();
let env = EnvGuard::new(&["HF_HUB_CACHE", "HF_HOME", "XDG_CACHE_HOME", "HOME"]);
env.set("HF_HUB_CACHE", tmp.path().to_str().unwrap());
assert!(ModelCache::detect_hf_hub_source("org/model").is_none());
}
#[test]
fn detect_hf_hub_records_source_without_copy() {
let _g = ENV_LOCK.lock().unwrap();
let tmp_hub = TempDir::new().unwrap();
let tmp_hf2q = TempDir::new().unwrap();
let env = EnvGuard::new(&["HF_HUB_CACHE", "HF_HOME", "XDG_CACHE_HOME", "HOME"]);
env.set("HF_HUB_CACHE", tmp_hub.path().to_str().unwrap());
let snap = tmp_hub
.path()
.join("models--org--model")
.join("snapshots")
.join("rev-x");
fs::create_dir_all(&snap).unwrap();
let marker = snap.join("model.safetensors");
fs::write(&marker, vec![0u8; 1024]).unwrap();
let hub_size_before = fs::metadata(&marker).unwrap().len();
let detected = ModelCache::detect_hf_hub_source("org/model").unwrap();
let mut cache = ModelCache::open_at(tmp_hf2q.path()).unwrap();
cache
.record_source(
"org/model",
&detected.revision,
SourcePointer::HfHub {
path: detected.path.clone(),
revision: detected.revision.clone(),
},
)
.unwrap();
let hf2q_total: u64 = walk_total(tmp_hf2q.path());
let hub_size_after = fs::metadata(&marker).unwrap().len();
assert_eq!(
hub_size_before, hub_size_after,
"HF cache file must not be modified"
);
assert!(
hf2q_total < 8 * 1024,
"hf2q cache should hold only json metadata, got {} bytes",
hf2q_total
);
let m = cache.lookup_model("org/model").unwrap();
match m.source.as_ref().unwrap() {
SourcePointer::HfHub { path, revision } => {
assert!(path.starts_with(tmp_hub.path()));
assert_eq!(revision, "rev-x");
}
other => panic!("expected HfHub, got {:?}", other),
}
}
fn walk_total(dir: &Path) -> u64 {
let mut total = 0u64;
for entry in fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let ty = entry.file_type().unwrap();
if ty.is_file() {
total += entry.metadata().unwrap().len();
} else if ty.is_dir() && !ty.is_symlink() {
total += walk_total(&entry.path());
}
}
total
}
#[test]
fn sha256_file_known_vector() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("hello.bin");
fs::write(&path, b"hello").unwrap();
assert_eq!(
sha256_file(&path).unwrap(),
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn sha256_file_empty() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("empty.bin");
fs::write(&path, b"").unwrap();
assert_eq!(
sha256_file(&path).unwrap(),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn compute_file_sha256_known_vector() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("hello.bin");
fs::write(&path, b"hello").unwrap();
assert_eq!(
compute_file_sha256(&path).unwrap(),
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn compute_file_sha256_empty() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("empty.bin");
fs::write(&path, b"").unwrap();
assert_eq!(
compute_file_sha256(&path).unwrap(),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn compute_file_sha256_multi_chunk_streams_correctly() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("multi.bin");
let mut buf = Vec::with_capacity(3 * 1024 * 1024 + 17);
for i in 0..(3 * 1024 * 1024 + 17) {
buf.push((i % 251) as u8); }
fs::write(&path, &buf).unwrap();
let mut hasher = Sha256::new();
hasher.update(&buf);
let expected = hex::encode(hasher.finalize());
assert_eq!(compute_file_sha256(&path).unwrap(), expected);
assert_eq!(expected.len(), 64);
assert!(expected
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
}
#[test]
fn compute_file_sha256_missing_file_yields_io_not_found() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("does-not-exist.bin");
let err = compute_file_sha256(&path).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn compute_file_sha256_matches_sha256_file_byte_for_byte() {
let tmp = TempDir::new().unwrap();
for (name, content) in [
("a.bin", &b""[..]),
("b.bin", &b"the quick brown fox jumps over the lazy dog"[..]),
("c.bin", &vec![0xABu8; 2 * 1024 * 1024 + 5][..]),
] {
let p = tmp.path().join(name);
fs::write(&p, content).unwrap();
assert_eq!(
compute_file_sha256(&p).unwrap(),
sha256_file(&p).unwrap(),
"drift between compute_file_sha256 and sha256_file for {name}"
);
}
}
#[test]
fn schema_v1_manifest_loads_with_empty_source_shards() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("models")).unwrap();
fs::create_dir_all(tmp.path().join("locks")).unwrap();
let v1_json = r#"{
"schema_version": 1,
"models": {
"old/model": {
"repo_id": "old/model",
"revision": "abc",
"source": null,
"quantizations": {},
"last_accessed_secs": 100
}
}
}"#;
fs::write(tmp.path().join("manifest.json"), v1_json).unwrap();
let cache = ModelCache::open_at(tmp.path()).unwrap();
let m = cache.lookup_model("old/model").expect("v1 entry loaded");
assert_eq!(m.revision, "abc");
assert!(
m.source_shards.is_empty(),
"v1 manifest must default source_shards to empty Vec"
);
assert_eq!(cache.manifest().schema_version, MANIFEST_SCHEMA_VERSION);
}
#[test]
fn schema_v1_manifest_persists_as_v2_on_next_write() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("models")).unwrap();
fs::create_dir_all(tmp.path().join("locks")).unwrap();
let v1_json = r#"{
"schema_version": 1,
"models": {}
}"#;
fs::write(tmp.path().join("manifest.json"), v1_json).unwrap();
{
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
cache
.record_source(
"x/y",
"rev",
SourcePointer::Local {
path: PathBuf::from("/p"),
sha256: "0".repeat(64),
},
)
.unwrap();
}
let raw = fs::read_to_string(tmp.path().join("manifest.json")).unwrap();
assert!(
raw.contains(r#""schema_version": 2"#) || raw.contains(r#""schema_version":2"#),
"v2 manifest must persist schema_version=2: {raw}"
);
}
#[test]
fn schema_unsupported_too_old_errors() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path()).unwrap();
fs::write(
tmp.path().join("manifest.json"),
r#"{"schema_version": 0, "models": {}}"#,
)
.unwrap();
let err = ModelCache::open_at(tmp.path()).unwrap_err();
assert!(format!("{err}").contains("schema_version"));
}
fn fake_shard(
filename: &str,
bytes: u64,
sha256: Option<&str>,
) -> crate::core::integrity::ShardIntegrity {
crate::core::integrity::ShardIntegrity {
filename: filename.to_string(),
bytes,
sha256: sha256.map(|s| s.to_string()),
hf_etag: sha256.unwrap_or("etag").to_string(),
is_lfs: sha256.is_some(),
}
}
#[test]
fn record_source_with_shards_persists_into_manifest() {
let tmp = TempDir::new().unwrap();
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
let shards = vec![
fake_shard(
"model-00001-of-00002.safetensors",
1024,
Some(&"a".repeat(64)),
),
fake_shard(
"model-00002-of-00002.safetensors",
2048,
Some(&"b".repeat(64)),
),
fake_shard("config.json", 200, None),
];
cache
.record_source_with_shards(
"org/m",
"rev1",
SourcePointer::HfHub {
path: PathBuf::from("/hub/path"),
revision: "rev1".into(),
},
shards.clone(),
)
.unwrap();
let m = cache.lookup_model("org/m").unwrap();
assert_eq!(m.source_shards.len(), 3);
assert_eq!(
m.source_shards[0].filename,
"model-00001-of-00002.safetensors"
);
assert_eq!(m.source_shards[0].bytes, 1024);
assert_eq!(m.source_shards[0].sha256.as_deref(), Some(&*"a".repeat(64)));
assert!(m.source_shards[0].is_lfs);
assert_eq!(m.source_shards[2].filename, "config.json");
assert!(!m.source_shards[2].is_lfs);
assert!(m.source_shards[0].verified_at_secs > 0);
}
#[test]
fn record_source_with_shards_survives_reopen() {
let tmp = TempDir::new().unwrap();
{
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
let shards = vec![fake_shard("a.safetensors", 8, Some(&"c".repeat(64)))];
cache
.record_source_with_shards(
"org/m",
"rev",
SourcePointer::HfHub {
path: PathBuf::from("/p"),
revision: "rev".into(),
},
shards,
)
.unwrap();
}
let cache = ModelCache::open_at(tmp.path()).unwrap();
let m = cache.lookup_model("org/m").unwrap();
assert_eq!(m.source_shards.len(), 1);
assert_eq!(m.source_shards[0].filename, "a.safetensors");
}
fn record_real_quant(tmp: &Path, repo: &str, contents: &[u8]) -> ModelCache {
let mut cache = ModelCache::open_at(tmp).unwrap();
cache
.record_source(
repo,
"rev",
SourcePointer::Local {
path: PathBuf::from("/p"),
sha256: "0".repeat(64),
},
)
.unwrap();
let gguf = cache_model_path(tmp, repo, QuantType::Q4_K_M).unwrap();
fs::create_dir_all(gguf.parent().unwrap()).unwrap();
fs::write(&gguf, contents).unwrap();
let entry = QuantEntry {
quant_type: QuantType::Q4_K_M.as_str().to_string(),
gguf_path: gguf.clone(),
mmproj_path: None,
bytes: contents.len() as u64,
sha256: sha256_file(&gguf).unwrap(),
quantized_at_secs: secs_since_epoch(),
quantized_by_version: env!("CARGO_PKG_VERSION").to_string(),
};
cache.record_quantized(repo, entry).unwrap();
cache
}
#[test]
fn verify_quantized_pass_when_bytes_match_manifest() {
let tmp = TempDir::new().unwrap();
let cache = record_real_quant(tmp.path(), "org/m", b"GGUF\0valid\0bytes");
cache
.verify_quantized("org/m", QuantType::Q4_K_M)
.expect("matching bytes should verify");
}
#[test]
fn verify_quantized_fail_when_bytes_mutated_after_record() {
let tmp = TempDir::new().unwrap();
let cache = record_real_quant(tmp.path(), "org/m", b"original");
let gguf = cache_model_path(tmp.path(), "org/m", QuantType::Q4_K_M).unwrap();
fs::write(&gguf, b"tampered").unwrap();
let err = cache
.verify_quantized("org/m", QuantType::Q4_K_M)
.expect_err("tampered bytes must be detected");
let msg = format!("{err}");
assert!(msg.contains("SHA-256 mismatch"), "msg: {msg}");
assert!(msg.contains("org/m"), "msg: {msg}");
assert!(msg.contains("Q4_K_M"), "msg: {msg}");
assert!(msg.contains("--no-integrity"), "msg: {msg}");
}
#[test]
fn verify_quantized_fail_when_no_manifest_entry() {
let tmp = TempDir::new().unwrap();
let cache = ModelCache::open_at(tmp.path()).unwrap();
let err = cache
.verify_quantized("ghost/repo", QuantType::Q4_K_M)
.expect_err("uncached repo must fail");
let msg = format!("{err}");
assert!(msg.contains("no manifest entry"), "msg: {msg}");
}
#[test]
fn verify_quantized_fail_when_gguf_deleted_from_disk() {
let tmp = TempDir::new().unwrap();
let cache = record_real_quant(tmp.path(), "org/m", b"valid");
let gguf = cache_model_path(tmp.path(), "org/m", QuantType::Q4_K_M).unwrap();
fs::remove_file(&gguf).unwrap();
let err = cache
.verify_quantized("org/m", QuantType::Q4_K_M)
.expect_err("missing gguf must fail");
let msg = format!("{err}");
assert!(msg.contains("missing on disk"), "msg: {msg}");
}
fn fab_two_quants(tmp: &Path, repo: &str) -> (ModelCache, PathBuf, PathBuf) {
let mut cache = ModelCache::open_at(tmp).unwrap();
cache
.record_source(
repo,
"rev",
SourcePointer::Local {
path: PathBuf::from("/p"),
sha256: "0".repeat(64),
},
)
.unwrap();
let g4 = cache_model_path(tmp, repo, QuantType::Q4_K_M).unwrap();
fs::create_dir_all(g4.parent().unwrap()).unwrap();
fs::write(&g4, b"Q4_K_M_BYTES_FOR_TEST").unwrap();
cache
.record_quantized(
repo,
QuantEntry {
quant_type: QuantType::Q4_K_M.as_str().to_string(),
gguf_path: g4.clone(),
mmproj_path: None,
bytes: 21,
sha256: sha256_file(&g4).unwrap(),
quantized_at_secs: secs_since_epoch(),
quantized_by_version: env!("CARGO_PKG_VERSION").to_string(),
},
)
.unwrap();
let g8 = cache_model_path(tmp, repo, QuantType::Q8_0).unwrap();
fs::create_dir_all(g8.parent().unwrap()).unwrap();
fs::write(&g8, b"Q8_0_BYTES_FOR_TEST_LARGER").unwrap();
cache
.record_quantized(
repo,
QuantEntry {
quant_type: QuantType::Q8_0.as_str().to_string(),
gguf_path: g8.clone(),
mmproj_path: None,
bytes: 26,
sha256: sha256_file(&g8).unwrap(),
quantized_at_secs: secs_since_epoch(),
quantized_by_version: env!("CARGO_PKG_VERSION").to_string(),
},
)
.unwrap();
(cache, g4, g8)
}
#[test]
fn invalidate_removes_quant_entry_and_files() {
let tmp = TempDir::new().unwrap();
let (mut cache, g4, g8) = fab_two_quants(tmp.path(), "org/m");
assert!(g4.is_file(), "Q4_K_M GGUF must be on disk pre-invalidate");
assert!(g8.is_file(), "Q8_0 GGUF must be on disk pre-invalidate");
assert!(cache.lookup("org/m", QuantType::Q4_K_M).is_some());
assert!(cache.lookup("org/m", QuantType::Q8_0).is_some());
let _freed = cache.invalidate("org/m", QuantType::Q4_K_M).unwrap();
assert!(!g4.exists(), "Q4_K_M GGUF must be removed");
assert!(!g4.parent().unwrap().exists(), "Q4_K_M dir must be removed");
assert!(g8.is_file(), "Q8_0 GGUF must survive");
assert!(cache.lookup("org/m", QuantType::Q4_K_M).is_none());
assert!(cache.lookup("org/m", QuantType::Q8_0).is_some());
}
#[test]
fn invalidate_returns_bytes_freed_matching_disk_walk() {
let tmp = TempDir::new().unwrap();
let (mut cache, g4, _g8) = fab_two_quants(tmp.path(), "org/m");
let pre = dir_total_bytes(g4.parent().unwrap());
assert!(pre > 0, "pre-bytes must be non-zero");
let freed = cache.invalidate("org/m", QuantType::Q4_K_M).unwrap();
assert_eq!(freed, pre, "freed bytes must match the pre-removal walk");
}
#[test]
fn invalidate_unknown_repo_errors_named() {
let tmp = TempDir::new().unwrap();
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
let err = cache
.invalidate("ghost/repo", QuantType::Q4_K_M)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("unknown_repo"), "msg: {msg}");
assert!(msg.contains("ghost/repo"), "msg: {msg}");
}
#[test]
fn invalidate_unknown_quant_errors_named() {
let tmp = TempDir::new().unwrap();
let (mut cache, _g4, _g8) = fab_two_quants(tmp.path(), "org/m");
let err = cache
.invalidate("org/m", QuantType::Q3_K_M) .unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("unknown_quant"), "msg: {msg}");
assert!(msg.contains("Q3_K_M"), "msg: {msg}");
}
#[test]
fn invalidate_persists_across_reopen() {
let tmp = TempDir::new().unwrap();
{
let (mut cache, _g4, _g8) = fab_two_quants(tmp.path(), "org/m");
cache.invalidate("org/m", QuantType::Q4_K_M).unwrap();
}
let cache = ModelCache::open_at(tmp.path()).unwrap();
assert!(cache.lookup("org/m", QuantType::Q4_K_M).is_none());
assert!(cache.lookup("org/m", QuantType::Q8_0).is_some());
}
#[test]
fn invalidate_holds_per_quant_lock_during_op() {
let tmp = TempDir::new().unwrap();
let (mut cache, _g4, _g8) = fab_two_quants(tmp.path(), "org/m");
cache.invalidate("org/m", QuantType::Q4_K_M).unwrap();
let l = cache.try_lock_quant("org/m", QuantType::Q4_K_M).unwrap();
assert!(
l.is_some(),
"post-invalidate lock must be released (RAII Drop fires before flush returns)"
);
}
#[test]
fn invalidate_repo_removes_all_quants_and_model_dir() {
let tmp = TempDir::new().unwrap();
let (mut cache, g4, g8) = fab_two_quants(tmp.path(), "org/m");
let model_dir = tmp
.path()
.join("models")
.join(slug_repo_id("org/m").unwrap());
assert!(model_dir.is_dir());
let freed = cache.invalidate_repo("org/m").unwrap();
assert!(freed > 0, "freed must be non-zero with two real quants");
assert!(!model_dir.exists(), "model dir must be removed");
assert!(!g4.exists());
assert!(!g8.exists());
assert!(cache.lookup_model("org/m").is_none());
}
#[test]
fn invalidate_repo_unknown_errors_named() {
let tmp = TempDir::new().unwrap();
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
let err = cache.invalidate_repo("ghost/repo").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("unknown_repo"), "msg: {msg}");
assert!(msg.contains("ghost/repo"), "msg: {msg}");
}
#[test]
fn invalidate_repo_handles_repo_with_no_quants() {
let tmp = TempDir::new().unwrap();
let mut cache = ModelCache::open_at(tmp.path()).unwrap();
cache
.record_source(
"org/m",
"rev",
SourcePointer::Local {
path: PathBuf::from("/p"),
sha256: "0".repeat(64),
},
)
.unwrap();
cache.invalidate_repo("org/m").unwrap();
assert!(cache.lookup_model("org/m").is_none());
}
#[test]
fn purge_removes_everything_and_resets_manifest() {
let tmp = TempDir::new().unwrap();
let (mut cache, _g4, _g8) = fab_two_quants(tmp.path(), "org/a");
cache
.record_source(
"org/b",
"rev",
SourcePointer::Local {
path: PathBuf::from("/p"),
sha256: "1".repeat(64),
},
)
.unwrap();
let freed = cache.purge().unwrap();
assert!(freed > 0);
assert!(cache.manifest().models.is_empty());
assert!(cache.lookup_model("org/a").is_none());
assert!(cache.lookup_model("org/b").is_none());
let models = tmp.path().join("models");
assert!(models.is_dir(), "models/ must be re-created post-purge");
let count = fs::read_dir(&models).unwrap().count();
assert_eq!(count, 0, "models/ must be empty post-purge");
}
#[test]
fn purge_preserves_schema_version_v2_on_reopen() {
let tmp = TempDir::new().unwrap();
{
let (mut cache, _g4, _g8) = fab_two_quants(tmp.path(), "org/a");
cache.purge().unwrap();
}
let cache = ModelCache::open_at(tmp.path()).unwrap();
assert_eq!(
cache.manifest().schema_version,
MANIFEST_SCHEMA_VERSION,
"purge must not regress schema version"
);
assert!(cache.manifest().models.is_empty());
}
#[test]
fn purge_idempotent_second_call_returns_zero() {
let tmp = TempDir::new().unwrap();
let (mut cache, _g4, _g8) = fab_two_quants(tmp.path(), "org/m");
let _first = cache.purge().unwrap();
let second = cache.purge().unwrap();
assert_eq!(second, 0, "second purge must report 0 bytes freed");
}
#[test]
fn iter_entries_lists_all_repos_and_quants() {
let tmp = TempDir::new().unwrap();
let (mut cache, _g4, _g8) = fab_two_quants(tmp.path(), "org/a");
let g_b = cache_model_path(tmp.path(), "org/b", QuantType::Q8_0).unwrap();
cache
.record_source(
"org/b",
"rev",
SourcePointer::Local {
path: PathBuf::from("/p"),
sha256: "2".repeat(64),
},
)
.unwrap();
fs::create_dir_all(g_b.parent().unwrap()).unwrap();
fs::write(&g_b, b"Q8_0").unwrap();
cache
.record_quantized(
"org/b",
QuantEntry {
quant_type: QuantType::Q8_0.as_str().to_string(),
gguf_path: g_b.clone(),
mmproj_path: None,
bytes: 4,
sha256: sha256_file(&g_b).unwrap(),
quantized_at_secs: secs_since_epoch(),
quantized_by_version: env!("CARGO_PKG_VERSION").to_string(),
},
)
.unwrap();
let entries: Vec<_> = cache.iter_entries().collect();
assert_eq!(entries.len(), 2, "two distinct repos");
assert_eq!(entries[0].repo_id, "org/a");
assert_eq!(entries[0].model.quantizations.len(), 2); assert_eq!(entries[1].repo_id, "org/b");
assert_eq!(entries[1].model.quantizations.len(), 1);
}
#[test]
fn total_bytes_on_disk_matches_walk() {
let tmp = TempDir::new().unwrap();
let (cache, _g4, _g8) = fab_two_quants(tmp.path(), "org/m");
let direct = dir_total_bytes(&tmp.path().join("models"));
assert_eq!(cache.total_bytes_on_disk(), direct);
assert!(cache.total_bytes_on_disk() > 0);
}
#[test]
fn dir_total_bytes_returns_zero_for_missing_path() {
let tmp = TempDir::new().unwrap();
let phantom = tmp.path().join("does/not/exist");
assert_eq!(dir_total_bytes(&phantom), 0);
}
#[test]
fn quant_type_round_trip_via_from_canonical_str() {
for q in [
QuantType::Q8_0,
QuantType::Q6_K,
QuantType::Q4_K_M,
QuantType::Q3_K_M,
] {
let parsed = QuantType::from_canonical_str(q.as_str()).unwrap();
assert_eq!(parsed, q, "round-trip for {}", q.as_str());
}
}
#[test]
fn quant_type_case_insensitive() {
assert_eq!(
QuantType::from_canonical_str("q4_k_m").unwrap(),
QuantType::Q4_K_M
);
assert_eq!(
QuantType::from_canonical_str("Q4_k_M").unwrap(),
QuantType::Q4_K_M
);
}
#[test]
fn quant_type_unknown_errors_lists_supported() {
let err = QuantType::from_canonical_str("Q5_K_S").unwrap_err();
assert!(err.contains("Q8_0"), "err: {err}");
assert!(err.contains("Q6_K"), "err: {err}");
assert!(err.contains("Q4_K_M"), "err: {err}");
assert!(err.contains("Q3_K_M"), "err: {err}");
assert!(err.contains("Q5_K_S"), "err: {err}");
}
}