modelshelf 0.1.0

A shared local LLM model registry: discover, deduplicate, download, and update models across desktop apps.
Documentation
//! Ecosystem scanners: find every local LLM model file on the machine.
//!
//! Each adapter knows one ecosystem's on-disk layout. All filesystem roots
//! flow through [`ScanEnv`] — adapters never read `$HOME` directly — so tests
//! can fake entire ecosystems inside a temp directory.

mod custom;
mod gpt4all;
mod hf_cache;
mod jan;
mod lmstudio;
mod ollama;

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use crate::registry::{Ecosystem, Source};

/// Where scanners look for ecosystem roots.
#[derive(Debug, Clone, Default)]
pub struct ScanEnv {
    /// Substitute home directory. `None` means the real user home.
    pub home_dir: Option<PathBuf>,
    /// Explicit per-ecosystem root overrides (used by tests and by users
    /// with relocated installs, e.g. `OLLAMA_MODELS`).
    pub roots: HashMap<Ecosystem, PathBuf>,
}

impl ScanEnv {
    /// The effective home directory, if any.
    pub fn home(&self) -> Option<PathBuf> {
        self.home_dir.clone().or_else(dirs::home_dir)
    }

    /// The override root for an ecosystem, if configured.
    pub fn root_override(&self, eco: Ecosystem) -> Option<&Path> {
        self.roots.get(&eco).map(PathBuf::as_path)
    }
}

/// A model file discovered by a scanner, before registry merge.
#[derive(Debug, Clone)]
pub struct FoundFile {
    /// Absolute path of the file.
    pub path: PathBuf,
    /// Which ecosystem it belongs to.
    pub ecosystem: Ecosystem,
    /// File size in bytes.
    pub size_bytes: u64,
    /// Best display name the scanner could derive (file stem, `model:tag`, …).
    pub display_name: String,
    /// sha256 hex known *without hashing* (content-addressed stores only:
    /// Ollama blob names, HF cache blob names). Trust-but-verify: `doctor`
    /// can re-verify, scans do not.
    pub known_sha256: Option<String>,
    /// Remote source mapping derived from the path layout, if the layout
    /// encodes one (HF cache, LM Studio publisher/repo trees).
    pub source: Option<Source>,
}

/// One ecosystem scanner.
pub trait Scanner {
    /// The ecosystem this scanner covers.
    fn ecosystem(&self) -> Ecosystem;

    /// Locate this ecosystem's root, if it exists on this machine.
    /// An explicit override in [`ScanEnv`] always wins.
    fn detect_root(&self, env: &ScanEnv) -> Option<PathBuf>;

    /// Enumerate model files under `root`. Unreadable subtrees are skipped,
    /// not errors: a scan should always produce its best effort.
    fn scan(&self, root: &Path) -> Vec<FoundFile>;
}

/// All built-in scanners.
pub fn all_scanners() -> Vec<Box<dyn Scanner>> {
    vec![
        Box::new(ollama::OllamaScanner),
        Box::new(lmstudio::LmStudioScanner),
        Box::new(hf_cache::HfCacheScanner),
        Box::new(jan::JanScanner),
        Box::new(gpt4all::Gpt4allScanner),
    ]
}

/// Run scanners (optionally restricted to `only`) plus custom roots.
pub fn scan_all(
    env: &ScanEnv,
    only: Option<&[Ecosystem]>,
    extra_roots: &[PathBuf],
) -> Vec<FoundFile> {
    let mut found = Vec::new();
    for scanner in all_scanners() {
        if let Some(only) = only {
            if !only.contains(&scanner.ecosystem()) {
                continue;
            }
        }
        if let Some(root) = scanner.detect_root(env) {
            tracing::debug!(ecosystem = ?scanner.ecosystem(), root = %root.display(), "scanning");
            found.extend(scanner.scan(&root));
        }
    }
    if only.is_none() || only.is_some_and(|o| o.contains(&Ecosystem::Custom)) {
        for root in extra_roots {
            found.extend(custom::scan_custom_root(root));
        }
    }
    found
}

/// Shared helper: walk `root` recursively collecting `.gguf` files.
pub(crate) fn walk_gguf(root: &Path, ecosystem: Ecosystem) -> Vec<FoundFile> {
    let mut out = Vec::new();
    for entry in walkdir::WalkDir::new(root)
        .follow_links(false)
        .into_iter()
        .filter_map(|e| e.ok())
    {
        if !entry.file_type().is_file() {
            continue;
        }
        let path = entry.path();
        if path
            .extension()
            .is_none_or(|e| !e.eq_ignore_ascii_case("gguf"))
        {
            continue;
        }
        let Ok(meta) = entry.metadata() else { continue };
        out.push(FoundFile {
            path: path.to_path_buf(),
            ecosystem,
            size_bytes: meta.len(),
            display_name: path
                .file_stem()
                .map(|s| s.to_string_lossy().into_owned())
                .unwrap_or_else(|| path.to_string_lossy().into_owned()),
            known_sha256: None,
            source: None,
        });
    }
    out
}

pub(crate) fn is_sha256_hex(s: &str) -> bool {
    s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}

/// Fixture builders that lay out structurally-real fake ecosystems for
/// integration tests. Enabled by the `test-fixtures` feature only.
#[cfg(any(test, feature = "test-fixtures"))]
pub mod fixtures {
    pub use super::hf_cache::fixtures as hf_cache;
    pub use super::ollama::fixtures as ollama;
}