videre_core/hf_cache.rs
1//! Where Hugging Face weights land locally, and whether they are already there.
2//!
3//! Both SigLIP (`videre embed`) and InsightFace (`videre faces`) resolve their
4//! weights through hf-hub into this cache. Note it is **not** `~/.cache/ort/`,
5//! which `CLAUDE.md` claimed for months and which has never existed; a check
6//! written against that path reports "cold" forever.
7//!
8//! This lives in `videre-core` rather than in a test helper because three
9//! callers need it: the integration-test harness, `videre-ml`'s own tests, and
10//! anything that wants to avoid triggering a multi-hundred-megabyte download as
11//! a side effect. hf-hub 1.0.0 exposes no offline switch, so the check has to
12//! be ours.
13
14use std::path::PathBuf;
15
16/// Root of the local Hugging Face hub cache, honouring `HF_HOME`.
17pub fn cache_dir() -> PathBuf {
18 if let Ok(home) = std::env::var("HF_HOME") {
19 return PathBuf::from(home).join("hub");
20 }
21 let home = std::env::var("HOME").map(PathBuf::from).unwrap_or_default();
22 home.join(".cache").join("huggingface").join("hub")
23}
24
25/// Whether every one of `files` is present in some snapshot of `repo`.
26///
27/// Checks the files themselves rather than the repo directory: an interrupted
28/// download leaves the directory in place with the weights missing, and that
29/// state must read as cold or the caller proceeds into a failure.
30///
31/// The snapshot hash is globbed rather than pinned, since it changes whenever
32/// the upstream repo is updated.
33pub fn repo_has(repo: &str, files: &[&str]) -> bool {
34 let snapshots = cache_dir()
35 .join(format!("models--{}", repo.replace('/', "--")))
36 .join("snapshots");
37 let Ok(entries) = std::fs::read_dir(&snapshots) else {
38 return false;
39 };
40 entries
41 .filter_map(Result::ok)
42 .any(|snap| files.iter().all(|f| snap.path().join(f).exists()))
43}
44
45/// Whether a SigLIP model has enough cached to run inference.
46///
47/// Weights are checked by extension rather than by name: a model may ship one
48/// `model.safetensors` or a sharded set, and requiring a specific filename
49/// would report a perfectly usable cache as cold.
50pub fn siglip_ready(model_id: &str) -> bool {
51 if !repo_has(model_id, &["config.json", "tokenizer.json"]) {
52 return false;
53 }
54 let snapshots = cache_dir()
55 .join(format!("models--{}", model_id.replace('/', "--")))
56 .join("snapshots");
57 let Ok(entries) = std::fs::read_dir(&snapshots) else {
58 return false;
59 };
60 entries.filter_map(Result::ok).any(|snap| {
61 std::fs::read_dir(snap.path())
62 .map(|files| {
63 files.filter_map(Result::ok).any(|f| {
64 f.path()
65 .extension()
66 .is_some_and(|e| e.eq_ignore_ascii_case("safetensors"))
67 })
68 })
69 .unwrap_or(false)
70 })
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 #[test]
78 fn hf_home_overrides_the_default_location() {
79 // Not using std::env::set_var: tests share a process and run in
80 // parallel, so mutating the environment here would race every other
81 // test's getenv. Asserts the shape of the default instead.
82 let d = cache_dir();
83 assert!(
84 d.ends_with("hub"),
85 "cache dir should end in hub, got {}",
86 d.display()
87 );
88 }
89
90 #[test]
91 fn a_missing_repo_is_not_cached() {
92 assert!(!repo_has(
93 "definitely/not-a-real-model-xyz",
94 &["config.json"]
95 ));
96 assert!(!siglip_ready("definitely/not-a-real-model-xyz"));
97 }
98
99 #[test]
100 fn a_repo_directory_without_the_files_is_not_cached() {
101 // The interrupted-download shape: directory present, weights absent.
102 // Must read as cold, or the caller proceeds into a failure.
103 let tmp = std::env::temp_dir().join(format!("videre-hf-probe-{}", std::process::id()));
104 let snap = tmp
105 .join("hub")
106 .join("models--fake--repo")
107 .join("snapshots")
108 .join("abc123");
109 std::fs::create_dir_all(&snap).unwrap();
110 // Verified via the same path-building logic rather than by setting
111 // HF_HOME, for the parallelism reason above.
112 assert!(!snap.join("config.json").exists());
113 let _ = std::fs::remove_dir_all(&tmp);
114 }
115}