Skip to main content

kernel/discovery/
lm_studio_scanner.rs

1//! Scans LM Studio's model tree: each root is walked for `.gguf` weight files
2//! (skipping multimodal projectors), which are grouped into models by
3//! [`discovered_models`]. The repo label is the `<publisher>/<model>` prefix of a
4//! file's path relative to the root.
5
6use std::path::{Path, PathBuf};
7
8use crate::discovery::gguf_models::{discovered_models, is_mmproj_name};
9use crate::discovery::scanner::{ScanResult, StoreScanner};
10use crate::records::SourceKind;
11
12/// A scanner over one or more LM Studio model roots.
13pub struct LMStudioScanner {
14    roots: Vec<PathBuf>,
15}
16
17impl LMStudioScanner {
18    /// A scanner over the given roots (a missing root is skipped).
19    pub fn new(roots: Vec<PathBuf>) -> Self {
20        Self { roots }
21    }
22
23    /// A scanner over a single root.
24    pub fn single(root: impl Into<PathBuf>) -> Self {
25        Self::new(vec![root.into()])
26    }
27
28    fn scan_root(&self, root: &Path, result: &mut ScanResult) {
29        if !root.exists() {
30            return;
31        }
32        let mut ggufs = Vec::new();
33        if collect_ggufs(root, &mut ggufs).is_err() {
34            if !result.failed_kinds.contains(&SourceKind::lm_studio()) {
35                result.failed_kinds.push(SourceKind::lm_studio());
36            }
37            return;
38        }
39        let (models, issues) =
40            discovered_models(&ggufs, &SourceKind::lm_studio(), |path| repo_of(path, root));
41        result.discovered.extend(models);
42        result.issues.extend(issues);
43    }
44}
45
46impl StoreScanner for LMStudioScanner {
47    fn kinds(&self) -> Vec<SourceKind> {
48        vec![SourceKind::lm_studio()]
49    }
50
51    fn scan(&self) -> ScanResult {
52        let mut result = ScanResult::default();
53        for root in &self.roots {
54            self.scan_root(root, &mut result);
55        }
56        result
57    }
58}
59
60/// The `<publisher>/<model>` repo of a file relative to `root`: everything but the
61/// final path component, when the file is at least three components deep.
62fn repo_of(path: &Path, root: &Path) -> Option<String> {
63    let relative = path.strip_prefix(root).ok()?;
64    let parts: Vec<String> = relative
65        .components()
66        .map(|component| component.as_os_str().to_string_lossy().into_owned())
67        .collect();
68    if parts.len() >= 3 {
69        Some(parts[..parts.len() - 1].join("/"))
70    } else {
71        None
72    }
73}
74
75/// Recursively collect `.gguf` weight files (non-projector, regular) under `dir`,
76/// with sizes (following symlinks). A top-level read error propagates; a
77/// subdirectory that can't be read is skipped.
78fn collect_ggufs(dir: &Path, into: &mut Vec<(PathBuf, i64)>) -> std::io::Result<()> {
79    for entry in std::fs::read_dir(dir)? {
80        let entry = entry?;
81        let path = entry.path();
82        if path
83            .file_name()
84            .and_then(|name| name.to_str())
85            .is_some_and(|name| name.starts_with('.'))
86        {
87            continue;
88        }
89        match entry.file_type() {
90            Ok(kind) if kind.is_dir() => {
91                let _ = collect_ggufs(&path, into);
92            }
93            _ if is_gguf_weight(&path) => {
94                if let Ok(meta) = std::fs::metadata(&path)
95                    && meta.is_file()
96                {
97                    into.push((path, meta.len() as i64));
98                }
99            }
100            _ => {}
101        }
102    }
103    Ok(())
104}
105
106fn is_gguf_weight(path: &Path) -> bool {
107    let name = path
108        .file_name()
109        .and_then(|name| name.to_str())
110        .unwrap_or_default();
111    if is_mmproj_name(name) {
112        return false;
113    }
114    path.extension()
115        .and_then(|ext| ext.to_str())
116        .is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
117}