modelshelf 0.1.0

A shared local LLM model registry: discover, deduplicate, download, and update models across desktop apps.
Documentation
//! LM Studio stores plain GGUF files in a `publisher/repo/file.gguf` tree.
//!
//! Default root is `~/.lmstudio/models` (newer versions), with the legacy
//! `~/.cache/lm-studio/models` as fallback. The two path components above the
//! file mirror the Hugging Face `org/repo` the model was downloaded from, so
//! a source mapping can be derived from the layout alone.

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

use super::{FoundFile, ScanEnv, Scanner};
use crate::registry::{Ecosystem, Source};

pub(crate) struct LmStudioScanner;

impl Scanner for LmStudioScanner {
    fn ecosystem(&self) -> Ecosystem {
        Ecosystem::Lmstudio
    }

    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());
        }
        let home = env.home()?;
        [
            home.join(".lmstudio").join("models"),
            home.join(".cache").join("lm-studio").join("models"),
        ]
        .into_iter()
        .find(|c| c.is_dir())
    }

    fn scan(&self, root: &Path) -> Vec<FoundFile> {
        let mut found = super::walk_gguf(root, Ecosystem::Lmstudio);
        for f in &mut found {
            f.source = source_from_layout(root, &f.path);
        }
        found
    }
}

/// `<root>/<publisher>/<repo>/[subdirs/]file.gguf` → `hf:publisher/repo`.
fn source_from_layout(root: &Path, file: &Path) -> Option<Source> {
    let rel = file.strip_prefix(root).ok()?;
    let mut parts = rel.components();
    let publisher = parts.next()?.as_os_str().to_str()?.to_owned();
    let repo = parts.next()?.as_os_str().to_str()?.to_owned();
    // The remaining components must include at least the file itself.
    let filename = file.file_name()?.to_str()?.to_owned();
    // rel must be at least publisher/repo/file.gguf; a two-component path
    // (publisher/file.gguf) has no repo level and is not HF-shaped.
    parts.next()?;
    Some(Source {
        kind: "huggingface".into(),
        repo: format!("{publisher}/{repo}"),
        filename,
        revision: None,
        resolved_at: crate::time_util::now_rfc3339(),
        extra: Default::default(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn derives_hf_source_from_tree_layout() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let file = root.join("bartowski/SomeModel-GGUF/SomeModel-Q4_K_M.gguf");
        crate::gguf::fixtures::write_gguf(&file, "Some Model", "llama", 15, 4096, b"A");
        let scanner = LmStudioScanner;
        let found = scanner.scan(root);
        assert_eq!(found.len(), 1);
        let src = found[0].source.as_ref().unwrap();
        assert_eq!(src.repo, "bartowski/SomeModel-GGUF");
        assert_eq!(src.filename, "SomeModel-Q4_K_M.gguf");
    }

    #[test]
    fn file_directly_under_publisher_gets_no_source() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let file = root.join("loose/model.gguf");
        crate::gguf::fixtures::write_gguf(&file, "Loose", "llama", 7, 2048, b"B");
        let found = LmStudioScanner.scan(root);
        assert_eq!(found.len(), 1);
        assert!(found[0].source.is_none());
    }
}