modelshelf 0.1.0

A shared local LLM model registry: discover, deduplicate, download, and update models across desktop apps.
Documentation
//! The Hugging Face hub cache:
//!
//! ```text
//! ~/.cache/huggingface/hub/
//! └── models--<org>--<repo>/
//!     ├── blobs/<sha256 or etag>          # actual bytes
//!     ├── refs/main                       # revision pointers
//!     └── snapshots/<revision>/<file>     # symlinks into blobs/
//! ```
//!
//! GGUF files appear as snapshot entries whose symlink target is an LFS blob
//! named by its sha256 — another free hash. The cache is managed by
//! `huggingface_hub` (its own locking and GC), so it is read-only for
//! modelshelf.

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;
                    }
                    // Resolve the snapshot symlink to the blob file; a blob
                    // named by 64 hex chars is an LFS object = free sha256.
                    let resolved =
                        std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
                    if !resolved.is_file() {
                        continue; // dangling symlink
                    }
                    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 {
                        // Record the snapshot path (stable, meaningful), not
                        // the blob: the blob may be shared by several repos.
                        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
    }
}

/// `models--org--repo` → `org/repo`. Repo names may themselves contain `--`
/// escaped double dashes are not used by HF; the first two segments are org
/// and the rest is the repo name.
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 {
    //! Build a fake HF hub cache for tests (blobs + snapshot links).
    use std::path::{Path, PathBuf};

    /// Lay out `content` as an LFS blob + snapshot entry. Returns the sha256.
    /// Uses a real symlink on Unix and a copy on Windows (CI-safe).
    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());
    }
}