Skip to main content

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}