kernel/discovery/
loose_file_scanner.rs1use 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
15const MAX_DEPTH: usize = 2;
17
18pub struct LooseFileScanner {
20 directories: Vec<PathBuf>,
21 user_directories: Vec<PathBuf>,
22}
23
24impl LooseFileScanner {
25 pub fn new(directories: Vec<PathBuf>) -> Self {
27 Self {
28 directories,
29 user_directories: Vec::new(),
30 }
31 }
32
33 pub fn single(directory: impl Into<PathBuf>) -> Self {
35 Self::new(vec![directory.into()])
36 }
37
38 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
119fn 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 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
219fn 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}