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