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 if let Some(modality) = &model.modality_hint {
157 record.modality = modality.clone();
158 }
159 if !model.capabilities_hint.is_empty() {
160 record.capabilities = model.capabilities_hint.clone();
161 }
162 if let Some(context) = model.context_length_hint {
163 record.context_length = Some(context);
164 }
165 if let Some(template) = model.has_chat_template_hint {
166 record.has_chat_template = Some(template);
167 }
168 if model.tool_capable_hint.is_some() {
169 record.supports_tools = model.tool_capable_hint;
170 }
171 if let Some(stops) = &model.stop_tokens_hint {
172 record.stop_tokens = Some(stops.clone());
173 }
174 record.execution = model.execution_hint;
175 record.downloading = model.downloading;
176 if record.state == ModelState::Missing {
177 record.state = ModelState::Unresolved;
178 }
179 to_register.push(record);
180 }
181 None => {
182 let mut record = ModelRecord::new(
183 &model.name,
184 model
185 .modality_hint
186 .clone()
187 .unwrap_or_else(Modality::unknown),
188 model.capabilities_hint.clone(),
189 model.source.clone(),
190 );
191 record.execution = model.execution_hint;
192 record.footprint_bytes = Some(model.footprint_bytes);
193 record.state = ModelState::Unresolved;
194 record.context_length = model.context_length_hint;
195 record.has_chat_template = model.has_chat_template_hint;
196 record.supports_tools = model.tool_capable_hint;
197 record.stop_tokens = model.stop_tokens_hint.clone();
198 record.primary_weight_path = model.primary_weight_path.clone();
199 record.downloading = model.downloading;
200 record.content_fingerprint = fingerprint(model, None);
201 to_register.push(record);
202 new_record_ids.insert(id);
203 }
204 }
205 }
206
207 let scanned_kinds: HashSet<SourceKind> = self
208 .scanners
209 .iter()
210 .flat_map(|scanner| scanner.kinds())
211 .filter(|kind| !failed_kinds.contains(kind))
212 .collect();
213
214 for record in &existing {
215 if scanned_kinds.contains(&record.source.kind)
216 && !seen_ids.contains(&record.id)
217 && record.state != ModelState::Missing
218 && !weights_present(record)
219 {
220 let mut stale = record.clone();
221 stale.state = ModelState::Missing;
222 to_register.push(stale);
223 }
224 }
225
226 let missing_candidates: Vec<&ModelRecord> = existing
227 .iter()
228 .filter(|record| {
229 !seen_ids.contains(&record.id)
230 && (record.state == ModelState::Missing
231 || scanned_kinds.contains(&record.source.kind))
232 })
233 .collect();
234 let migrated_away =
235 migrate_moved_config(&mut to_register, &new_record_ids, &missing_candidates);
236
237 let keep: Vec<ModelRecord> = to_register
238 .into_iter()
239 .filter(|record| !migrated_away.contains(&record.id))
240 .collect();
241 registry.register_all(keep)?;
242 for id in &migrated_away {
243 registry.unregister(id)?;
244 }
245
246 let mut per_kind: BTreeMap<SourceKind, KindStat> = BTreeMap::new();
247 for model in &discovered {
248 let stat = per_kind.entry(model.source.kind.clone()).or_default();
249 stat.count += 1;
250 stat.bytes += model.footprint_bytes;
251 }
252 let duplicate_candidates: Vec<(String, PathBuf)> = discovered
253 .iter()
254 .filter_map(|model| {
255 model
256 .primary_weight_path
257 .as_ref()
258 .map(|path| (model.name.clone(), PathBuf::from(path)))
259 })
260 .collect();
261
262 Ok(DiscoverySummary {
263 total_count: discovered.len(),
264 total_bytes: discovered.iter().map(|model| model.footprint_bytes).sum(),
265 duplicates: detect(&duplicate_candidates, self.duplicate_threshold),
266 per_kind,
267 issues,
268 failed_kinds: failed_kinds.into_iter().collect(),
269 })
270 }
271}
272
273fn weights_present(record: &ModelRecord) -> bool {
277 let Some(path) = record
278 .primary_weight_path
279 .as_deref()
280 .filter(|path| !path.is_empty())
281 else {
282 return false;
283 };
284 Path::new(path).exists() || Path::new(&record.source.path).exists()
285}
286
287fn same_size(recorded: Option<i64>, scanned: Option<i64>) -> bool {
294 let mebibytes = |bytes: Option<i64>| bytes.map(|bytes| bytes / BYTES_PER_MIB);
295 mebibytes(recorded) == mebibytes(scanned)
296}
297
298fn fingerprint(model: &DiscoveredModel, existing: Option<&ModelRecord>) -> Option<String> {
302 let Some(path) = &model.primary_weight_path else {
303 return existing.and_then(|record| record.content_fingerprint.clone());
304 };
305 if let Some(existing) = existing
306 && let Some(known) = &existing.content_fingerprint
307 && existing.primary_weight_path.as_deref() == Some(path.as_str())
308 && same_size(existing.footprint_bytes, Some(model.footprint_bytes))
309 {
310 return Some(known.clone());
311 }
312 content_fingerprint(Path::new(path))
313}
314
315fn migrate_moved_config(
319 to_register: &mut [ModelRecord],
320 new_record_ids: &HashSet<String>,
321 missing_candidates: &[&ModelRecord],
322) -> HashSet<String> {
323 let mut claimed: HashSet<String> = HashSet::new();
324 for record in to_register.iter_mut() {
325 if !new_record_ids.contains(&record.id) {
326 continue;
327 }
328 let Some(fingerprint) = record.content_fingerprint.clone() else {
329 continue;
330 };
331 let footprint = record.footprint_bytes;
332 let orphan = {
335 let mut matches = missing_candidates.iter().filter(|candidate| {
336 candidate.content_fingerprint.as_deref() == Some(fingerprint.as_str())
337 && same_size(candidate.footprint_bytes, footprint)
338 && !claimed.contains(&candidate.id)
339 });
340 match (matches.next(), matches.next()) {
341 (Some(orphan), None) => Some((
342 orphan.id.clone(),
343 orphan.param_values.clone(),
344 orphan.system_prompt.clone(),
345 orphan.alias.clone(),
346 )),
347 _ => None,
348 }
349 };
350 let Some((orphan_id, param_values, system_prompt, alias)) = orphan else {
351 continue;
352 };
353 record.param_values = param_values;
354 record.system_prompt = system_prompt;
355 record.alias = alias;
356 claimed.insert(orphan_id);
357 }
358 claimed
359}