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