use std::path::{Path, PathBuf};
use super::{FoundFile, ScanEnv, Scanner};
use crate::registry::{Ecosystem, Source};
pub(crate) struct HfCacheScanner;
impl Scanner for HfCacheScanner {
fn ecosystem(&self) -> Ecosystem {
Ecosystem::HfCache
}
fn detect_root(&self, env: &ScanEnv) -> Option<PathBuf> {
if let Some(root) = env.root_override(self.ecosystem()) {
return root.is_dir().then(|| root.to_path_buf());
}
if let Some(hf_home) = std::env::var_os("HF_HOME") {
let p = PathBuf::from(hf_home).join("hub");
if p.is_dir() {
return Some(p);
}
}
let candidate = env.home()?.join(".cache").join("huggingface").join("hub");
candidate.is_dir().then_some(candidate)
}
fn scan(&self, root: &Path) -> Vec<FoundFile> {
let mut found = Vec::new();
let Ok(repos) = std::fs::read_dir(root) else {
return found;
};
for repo_dir in repos.filter_map(|e| e.ok()) {
let dir_name = repo_dir.file_name().to_string_lossy().into_owned();
let Some(repo) = repo_from_dir_name(&dir_name) else {
continue;
};
let snapshots = repo_dir.path().join("snapshots");
let Ok(revisions) = std::fs::read_dir(&snapshots) else {
continue;
};
for rev_dir in revisions.filter_map(|e| e.ok()) {
let revision = rev_dir.file_name().to_string_lossy().into_owned();
for entry in walkdir::WalkDir::new(rev_dir.path())
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path
.extension()
.is_none_or(|e| !e.eq_ignore_ascii_case("gguf"))
{
continue;
}
let resolved =
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if !resolved.is_file() {
continue; }
let known_sha256 = resolved
.file_name()
.and_then(|n| n.to_str())
.filter(|n| super::is_sha256_hex(n))
.map(str::to_owned);
let Ok(meta) = std::fs::metadata(&resolved) else {
continue;
};
let filename = path
.strip_prefix(rev_dir.path())
.map(|r| r.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
found.push(FoundFile {
path: path.to_path_buf(),
ecosystem: Ecosystem::HfCache,
size_bytes: meta.len(),
display_name: path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(),
known_sha256,
source: Some(Source {
kind: "huggingface".into(),
repo: repo.clone(),
filename,
revision: Some(revision.clone()),
resolved_at: crate::time_util::now_rfc3339(),
extra: Default::default(),
}),
});
}
}
}
found
}
}
fn repo_from_dir_name(dir: &str) -> Option<String> {
let rest = dir.strip_prefix("models--")?;
let (org, repo) = rest.split_once("--")?;
Some(format!("{org}/{repo}"))
}
#[cfg(any(test, feature = "test-fixtures"))]
pub mod fixtures {
use std::path::{Path, PathBuf};
pub fn add_model(
root: &Path,
repo: &str,
revision: &str,
filename: &str,
content: &[u8],
) -> String {
use sha2::Digest;
let hex = {
let mut s = String::new();
for b in sha2::Sha256::digest(content) {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
};
let repo_dir: PathBuf = root.join(format!("models--{}", repo.replace('/', "--")));
let blobs = repo_dir.join("blobs");
let snapshot = repo_dir.join("snapshots").join(revision);
std::fs::create_dir_all(&blobs).unwrap();
std::fs::create_dir_all(&snapshot).unwrap();
let blob_path = blobs.join(&hex);
std::fs::write(&blob_path, content).unwrap();
let link = snapshot.join(filename);
#[cfg(unix)]
std::os::unix::fs::symlink(&blob_path, &link).unwrap();
#[cfg(not(unix))]
std::fs::copy(&blob_path, &link).map(|_| ()).unwrap();
hex
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_gguf_snapshot_with_repo_revision_and_free_hash() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let content = crate::gguf::fixtures::gguf_bytes("HF Model", "qwen2", 7, 32768, b"HF");
let hex = fixtures::add_model(
root,
"Qwen/Qwen-Test-GGUF",
"abc123rev",
"qwen-test-q8_0.gguf",
&content,
);
let found = HfCacheScanner.scan(root);
assert_eq!(found.len(), 1);
let f = &found[0];
#[cfg(unix)]
assert_eq!(f.known_sha256.as_deref(), Some(hex.as_str()));
#[cfg(not(unix))]
let _ = hex;
let src = f.source.as_ref().unwrap();
assert_eq!(src.repo, "Qwen/Qwen-Test-GGUF");
assert_eq!(src.revision.as_deref(), Some("abc123rev"));
assert_eq!(src.filename, "qwen-test-q8_0.gguf");
}
#[test]
fn non_gguf_snapshot_files_are_ignored() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fixtures::add_model(root, "org/repo", "rev", "config.json", b"{}");
assert!(HfCacheScanner.scan(root).is_empty());
}
}