Skip to main content

kernel/discovery/
loose_file_scanner.rs

1//! Scans loose directories (Downloads, a Models folder) up to a shallow depth
2//! for models a user dropped in by hand: a `config.json`+`safetensors` folder
3//! becomes one bundle model, loose `.gguf` files group into models, and a
4//! GGML-magic `.bin` is a whisper transcription model.
5
6use std::collections::BTreeSet;
7use std::path::{Path, PathBuf};
8
9use crate::discovery::gguf_models::{discovered_models, is_mmproj_name};
10use crate::discovery::modality_hints::{self, Hint};
11use crate::discovery::scanner::{DiscoveredModel, ScanResult, StoreScanner};
12use crate::records::{ExecutionMode, ModelSource, SourceKind};
13use crate::resolution::has_ggml_magic;
14
15/// How deep to sweep below each root (0 = the root's own entries).
16const MAX_DEPTH: usize = 2;
17
18/// A scanner over loose model directories.
19pub struct LooseFileScanner {
20    directories: Vec<PathBuf>,
21    user_directories: Vec<PathBuf>,
22}
23
24impl LooseFileScanner {
25    /// A scanner over the given directories (a missing one is skipped).
26    pub fn new(directories: Vec<PathBuf>) -> Self {
27        Self {
28            directories,
29            user_directories: Vec::new(),
30        }
31    }
32
33    /// A scanner over a single directory.
34    pub fn single(directory: impl Into<PathBuf>) -> Self {
35        Self::new(vec![directory.into()])
36    }
37
38    /// A scanner over standard `directories` (missing → skipped) plus
39    /// `user_directories` (missing → a scan failure).
40    pub fn with_user_directories(
41        directories: Vec<PathBuf>,
42        user_directories: Vec<PathBuf>,
43    ) -> Self {
44        Self {
45            directories,
46            user_directories,
47        }
48    }
49
50    fn scan_root(&self, dir: &Path, required: bool, result: &mut ScanResult) {
51        if !dir.exists() {
52            if required {
53                mark_failed(result);
54            }
55            return;
56        }
57        if std::fs::read_dir(dir).is_err() {
58            mark_failed(result);
59            return;
60        }
61        self.sweep(dir, 0, result);
62    }
63
64    fn sweep(&self, dir: &Path, depth: usize, result: &mut ScanResult) {
65        if depth > MAX_DEPTH {
66            return;
67        }
68        let Ok(entries) = std::fs::read_dir(dir) else {
69            return;
70        };
71
72        let mut ggufs: Vec<(PathBuf, i64)> = Vec::new();
73        for entry in entries.flatten() {
74            let path = entry.path();
75            if is_hidden(&path) {
76                continue;
77            }
78            match entry.file_type() {
79                Ok(kind) if kind.is_dir() => match folder_bundle(&path) {
80                    Some(bundle) => result.discovered.push(bundle),
81                    None => self.sweep(&path, depth + 1, result),
82                },
83                _ => {
84                    let size = std::fs::metadata(&path)
85                        .map(|meta| meta.len() as i64)
86                        .unwrap_or(0);
87                    if is_gguf_weight(&path) {
88                        ggufs.push((path, size));
89                    } else if is_ggml_bin(&path) {
90                        result.discovered.push(whisper_model(&path, size));
91                    }
92                }
93            }
94        }
95
96        let (models, issues) = discovered_models(&ggufs, &SourceKind::file(), |_| None);
97        result.discovered.extend(models);
98        result.issues.extend(issues);
99    }
100}
101
102impl StoreScanner for LooseFileScanner {
103    fn kinds(&self) -> Vec<SourceKind> {
104        vec![SourceKind::file(), SourceKind::folder()]
105    }
106
107    fn scan(&self) -> ScanResult {
108        let mut result = ScanResult::default();
109        for dir in &self.directories {
110            self.scan_root(dir, false, &mut result);
111        }
112        for dir in &self.user_directories {
113            self.scan_root(dir, true, &mut result);
114        }
115        result
116    }
117}
118
119/// A `config.json` + `safetensors` directory as a single folder-bundle model, or
120/// `None` if it isn't one.
121fn folder_bundle(dir: &Path) -> Option<DiscoveredModel> {
122    let mut names = BTreeSet::new();
123    let mut entries: Vec<(PathBuf, i64)> = Vec::new();
124    for entry in std::fs::read_dir(dir).ok()?.flatten() {
125        let path = entry.path();
126        if is_hidden(&path) {
127            continue;
128        }
129        if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
130            names.insert(name.to_owned());
131        }
132        let size = std::fs::metadata(&path)
133            .ok()
134            .filter(|meta| meta.is_file())
135            .map(|meta| meta.len() as i64)
136            .unwrap_or(0);
137        entries.push((path, size));
138    }
139
140    let has_safetensors = entries.iter().any(|(path, _)| is_safetensors(path));
141    if !names.contains("config.json") || !has_safetensors {
142        return None;
143    }
144
145    let mut hint = modality_hints::from_config_json(&dir.join("config.json"))
146        .unwrap_or_else(|| Hint::unknown(ExecutionMode::Sync));
147    if names.contains("model_index.json") {
148        hint = modality_hints::from_model_index(&dir.join("model_index.json"));
149    }
150
151    let total: i64 = entries.iter().map(|(_, size)| size).sum();
152    // First of equal-size safetensors wins (strict `>`) — `max_by_key` would
153    // keep the last.
154    let mut largest: Option<(&Path, i64)> = None;
155    for (path, size) in entries.iter().filter(|(path, _)| is_safetensors(path)) {
156        if largest.is_none_or(|(_, best)| *size > best) {
157            largest = Some((path, *size));
158        }
159    }
160    let largest = largest.map(|(path, _)| display(path));
161
162    let mut model = DiscoveredModel::new(
163        dir.file_name()
164            .and_then(|name| name.to_str())
165            .unwrap_or_default(),
166        ModelSource::new(SourceKind::folder(), &display(dir)),
167    );
168    model.modality_hint = hint.modality;
169    model.capabilities_hint = hint.capabilities;
170    model.execution_hint = hint.execution;
171    model.footprint_bytes = total;
172    model.primary_weight_path = largest;
173    model.context_length_hint = hint.context_length;
174    Some(model)
175}
176
177fn whisper_model(path: &Path, size: i64) -> DiscoveredModel {
178    let hint = modality_hints::whisper_bin_hint();
179    let name = path
180        .file_stem()
181        .and_then(|stem| stem.to_str())
182        .unwrap_or_default();
183    let mut model =
184        DiscoveredModel::new(name, ModelSource::new(SourceKind::file(), &display(path)));
185    model.modality_hint = hint.modality;
186    model.capabilities_hint = hint.capabilities;
187    model.execution_hint = hint.execution;
188    model.footprint_bytes = size;
189    model.primary_weight_path = Some(display(path));
190    model
191}
192
193fn mark_failed(result: &mut ScanResult) {
194    for kind in [SourceKind::file(), SourceKind::folder()] {
195        if !result.failed_kinds.contains(&kind) {
196            result.failed_kinds.push(kind);
197        }
198    }
199}
200
201fn is_hidden(path: &Path) -> bool {
202    path.file_name()
203        .and_then(|name| name.to_str())
204        .is_some_and(|name| name.starts_with('.'))
205}
206
207fn is_gguf_weight(path: &Path) -> bool {
208    let name = path
209        .file_name()
210        .and_then(|name| name.to_str())
211        .unwrap_or_default();
212    !is_mmproj_name(name) && has_extension_ignoring_case(path, "gguf")
213}
214
215fn is_ggml_bin(path: &Path) -> bool {
216    has_extension_ignoring_case(path, "bin") && has_ggml_magic(path)
217}
218
219/// A case-sensitive `.safetensors` extension check.
220fn is_safetensors(path: &Path) -> bool {
221    path.extension().and_then(|ext| ext.to_str()) == Some("safetensors")
222}
223
224fn has_extension_ignoring_case(path: &Path, extension: &str) -> bool {
225    path.extension()
226        .and_then(|ext| ext.to_str())
227        .is_some_and(|ext| ext.eq_ignore_ascii_case(extension))
228}
229
230fn display(path: &Path) -> String {
231    path.to_string_lossy().into_owned()
232}