1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use crate::discovery::duplicates::{
11 DEFAULT_THRESHOLD, DuplicateGroup, content_fingerprint, detect,
12};
13use crate::discovery::scanner::{DiscoveredModel, StoreScanner};
14use crate::records::byte_format::BYTES_PER_MIB;
15use crate::records::{Modality, ModelRecord, ModelState, SourceKind, format_bytes, stable_id};
16use crate::registry::{Registry, RegistryError};
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20pub struct KindStat {
21 pub count: usize,
23 pub bytes: i64,
25}
26
27#[derive(Debug, Clone, Default, PartialEq)]
30pub struct DiscoverySummary {
31 pub per_kind: BTreeMap<SourceKind, KindStat>,
33 pub total_count: usize,
35 pub total_bytes: i64,
37 pub duplicates: Vec<DuplicateGroup>,
39 pub issues: Vec<String>,
41 pub failed_kinds: Vec<SourceKind>,
43}
44
45impl DiscoverySummary {
46 pub fn headline(&self) -> String {
48 if self.total_count == 0 {
49 return "No models found on this Mac yet.".to_owned();
50 }
51 let ordered = [
52 (SourceKind::ollama(), "in Ollama"),
53 (SourceKind::huggingface_cache(), "in the Hugging Face cache"),
54 (SourceKind::lm_studio(), "in LM Studio"),
55 (SourceKind::builtin(), "built in"),
56 ];
57 let mut parts = Vec::new();
58 for (kind, label) in ordered {
59 if let Some(stat) = self.per_kind.get(&kind)
60 && stat.count > 0
61 {
62 parts.push(format!("{} {label}", stat.count));
63 }
64 }
65 let loose = self.kind_count(&SourceKind::file()) + self.kind_count(&SourceKind::folder());
66 if loose == 1 {
67 parts.push("1 loose file".to_owned());
68 } else if loose > 1 {
69 parts.push(format!("{loose} loose files"));
70 }
71 let models = if self.total_count == 1 {
72 "1 model".to_owned()
73 } else {
74 format!("{} models", self.total_count)
75 };
76 let breakdown = if parts.is_empty() {
77 String::new()
78 } else {
79 format!(" — {}", parts.join(", "))
80 };
81 format!(
82 "Found {models} on this Mac{breakdown}. Total: {}.",
83 format_bytes(self.total_bytes)
84 )
85 }
86
87 fn kind_count(&self, kind: &SourceKind) -> usize {
88 self.per_kind.get(kind).map_or(0, |stat| stat.count)
89 }
90}
91
92pub struct DiscoveryService {
94 scanners: Vec<Box<dyn StoreScanner>>,
95 duplicate_threshold: i64,
96}
97
98impl DiscoveryService {
99 pub fn new(scanners: Vec<Box<dyn StoreScanner>>) -> Self {
101 Self::with_threshold(scanners, DEFAULT_THRESHOLD)
102 }
103
104 pub fn with_threshold(scanners: Vec<Box<dyn StoreScanner>>, duplicate_threshold: i64) -> Self {
106 Self {
107 scanners,
108 duplicate_threshold,
109 }
110 }
111
112 pub fn discover(&self, registry: &mut Registry) -> Result<DiscoverySummary, RegistryError> {
117 let mut discovered: Vec<DiscoveredModel> = Vec::new();
118 let mut issues: Vec<String> = Vec::new();
119 let mut failed_kinds: BTreeSet<SourceKind> = BTreeSet::new();
120 for scanner in &self.scanners {
121 let result = scanner.scan();
122 discovered.extend(result.discovered);
123 issues.extend(result.issues);
124 failed_kinds.extend(result.failed_kinds);
125 }
126 for kind in &failed_kinds {
127 issues.push(format!(
128 "skipped the missing check for {} — its store could not be read",
129 kind.as_str()
130 ));
131 }
132
133 let existing: Vec<ModelRecord> = registry.list().into_iter().cloned().collect();
134 let existing_by_id: HashMap<&str, &ModelRecord> = existing
135 .iter()
136 .map(|record| (record.id.as_str(), record))
137 .collect();
138
139 let mut seen_ids: HashSet<String> = HashSet::new();
140 let mut to_register: Vec<ModelRecord> = Vec::new();
141 let mut new_record_ids: HashSet<String> = HashSet::new();
142
143 for model in &discovered {
144 let id = stable_id(&model.source);
145 if !seen_ids.insert(id.clone()) {
146 continue;
147 }
148 match existing_by_id.get(id.as_str()) {
149 Some(existing_record) => {
150 let mut record = (*existing_record).clone();
151 record.content_fingerprint = fingerprint(model, Some(existing_record));
152 record.name = model.name.clone();
153 record.source = model.source.clone();
154 record.footprint_bytes = Some(model.footprint_bytes);
155 record.primary_weight_path = model.primary_weight_path.clone();
156 let mut modality_moved = false;
161 if let Some(modality) = &model.modality_hint {
162 modality_moved = record.modality != *modality;
163 record.modality = modality.clone();
164 }
165 if modality_moved || !model.capabilities_hint.is_empty() {
166 record.capabilities = model.capabilities_hint.clone();
167 }
168 if let Some(context) = model.context_length_hint {
169 record.context_length = Some(context);
170 }
171 if let Some(template) = model.has_chat_template_hint {
172 record.has_chat_template = Some(template);
173 }
174 if model.tool_capable_hint.is_some() {
175 record.supports_tools = model.tool_capable_hint;
176 }
177 if let Some(stops) = &model.stop_tokens_hint {
178 record.stop_tokens = Some(stops.clone());
179 }
180 record.execution = model.execution_hint;
181 record.downloading = model.downloading;
182 if record.state == ModelState::Missing {
183 record.state = ModelState::Unresolved;
184 }
185 to_register.push(record);
186 }
187 None => {
188 let mut record = ModelRecord::new(
189 &model.name,
190 model
191 .modality_hint
192 .clone()
193 .unwrap_or_else(Modality::unknown),
194 model.capabilities_hint.clone(),
195 model.source.clone(),
196 );
197 record.execution = model.execution_hint;
198 record.footprint_bytes = Some(model.footprint_bytes);
199 record.state = ModelState::Unresolved;
200 record.context_length = model.context_length_hint;
201 record.has_chat_template = model.has_chat_template_hint;
202 record.supports_tools = model.tool_capable_hint;
203 record.stop_tokens = model.stop_tokens_hint.clone();
204 record.primary_weight_path = model.primary_weight_path.clone();
205 record.downloading = model.downloading;
206 record.content_fingerprint = fingerprint(model, None);
207 to_register.push(record);
208 new_record_ids.insert(id);
209 }
210 }
211 }
212
213 let scanned_kinds: HashSet<SourceKind> = self
214 .scanners
215 .iter()
216 .flat_map(|scanner| scanner.kinds())
217 .filter(|kind| !failed_kinds.contains(kind))
218 .collect();
219
220 for record in &existing {
221 if scanned_kinds.contains(&record.source.kind)
222 && !seen_ids.contains(&record.id)
223 && record.state != ModelState::Missing
224 && !weights_present(record)
225 {
226 let mut stale = record.clone();
227 stale.state = ModelState::Missing;
228 to_register.push(stale);
229 }
230 }
231
232 let missing_candidates: Vec<&ModelRecord> = existing
233 .iter()
234 .filter(|record| {
235 !seen_ids.contains(&record.id)
236 && (record.state == ModelState::Missing
237 || scanned_kinds.contains(&record.source.kind))
238 })
239 .collect();
240 let migrated_away =
241 migrate_moved_config(&mut to_register, &new_record_ids, &missing_candidates);
242
243 let keep: Vec<ModelRecord> = to_register
244 .into_iter()
245 .filter(|record| !migrated_away.contains(&record.id))
246 .collect();
247 registry.register_all(keep)?;
248 for id in &migrated_away {
249 registry.unregister(id)?;
250 }
251
252 let mut per_kind: BTreeMap<SourceKind, KindStat> = BTreeMap::new();
253 for model in &discovered {
254 let stat = per_kind.entry(model.source.kind.clone()).or_default();
255 stat.count += 1;
256 stat.bytes += model.footprint_bytes;
257 }
258 let duplicate_candidates: Vec<(String, PathBuf)> = discovered
259 .iter()
260 .filter_map(|model| {
261 model
262 .primary_weight_path
263 .as_ref()
264 .map(|path| (model.name.clone(), PathBuf::from(path)))
265 })
266 .collect();
267
268 Ok(DiscoverySummary {
269 total_count: discovered.len(),
270 total_bytes: discovered.iter().map(|model| model.footprint_bytes).sum(),
271 duplicates: detect(&duplicate_candidates, self.duplicate_threshold),
272 per_kind,
273 issues,
274 failed_kinds: failed_kinds.into_iter().collect(),
275 })
276 }
277}
278
279fn weights_present(record: &ModelRecord) -> bool {
283 let Some(path) = record
284 .primary_weight_path
285 .as_deref()
286 .filter(|path| !path.is_empty())
287 else {
288 return false;
289 };
290 Path::new(path).exists() || Path::new(&record.source.path).exists()
291}
292
293fn same_size(recorded: Option<i64>, scanned: Option<i64>) -> bool {
300 let mebibytes = |bytes: Option<i64>| bytes.map(|bytes| bytes / BYTES_PER_MIB);
301 mebibytes(recorded) == mebibytes(scanned)
302}
303
304fn fingerprint(model: &DiscoveredModel, existing: Option<&ModelRecord>) -> Option<String> {
308 let Some(path) = &model.primary_weight_path else {
309 return existing.and_then(|record| record.content_fingerprint.clone());
310 };
311 if let Some(existing) = existing
312 && let Some(known) = &existing.content_fingerprint
313 && existing.primary_weight_path.as_deref() == Some(path.as_str())
314 && same_size(existing.footprint_bytes, Some(model.footprint_bytes))
315 {
316 return Some(known.clone());
317 }
318 content_fingerprint(Path::new(path))
319}
320
321fn migrate_moved_config(
325 to_register: &mut [ModelRecord],
326 new_record_ids: &HashSet<String>,
327 missing_candidates: &[&ModelRecord],
328) -> HashSet<String> {
329 let mut claimed: HashSet<String> = HashSet::new();
330 for record in to_register.iter_mut() {
331 if !new_record_ids.contains(&record.id) {
332 continue;
333 }
334 let Some(fingerprint) = record.content_fingerprint.clone() else {
335 continue;
336 };
337 let footprint = record.footprint_bytes;
338 let orphan = {
341 let mut matches = missing_candidates.iter().filter(|candidate| {
342 candidate.content_fingerprint.as_deref() == Some(fingerprint.as_str())
343 && same_size(candidate.footprint_bytes, footprint)
344 && !claimed.contains(&candidate.id)
345 });
346 match (matches.next(), matches.next()) {
347 (Some(orphan), None) => Some((
348 orphan.id.clone(),
349 orphan.param_values.clone(),
350 orphan.system_prompt.clone(),
351 orphan.alias.clone(),
352 )),
353 _ => None,
354 }
355 };
356 let Some((orphan_id, param_values, system_prompt, alias)) = orphan else {
357 continue;
358 };
359 record.param_values = param_values;
360 record.system_prompt = system_prompt;
361 record.alias = alias;
362 claimed.insert(orphan_id);
363 }
364 claimed
365}