Skip to main content

kernel/discovery/
service.rs

1//! [`DiscoveryService`]: the discovery capstone. It runs the store scanners and
2//! reconciles what they found against the registry — updating known records,
3//! registering new ones, marking on-disk-gone models missing (guarded by a
4//! weights-present check), migrating a moved model's saved config onto its new
5//! record, and summarizing the result.
6
7use 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/// The count and byte total of models found for one source kind.
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub struct KindStat {
22    /// How many models were found.
23    pub count: usize,
24    /// Their combined on-disk size in bytes.
25    pub bytes: i64,
26}
27
28/// The outcome of a discovery pass: per-kind counts, totals, duplicate groups,
29/// non-fatal issues, and the store kinds whose scan failed.
30#[derive(Debug, Clone, Default, PartialEq)]
31pub struct DiscoverySummary {
32    /// Per source-kind stats.
33    pub per_kind: BTreeMap<SourceKind, KindStat>,
34    /// Total models found across all stores.
35    pub total_count: usize,
36    /// Total bytes across all found models.
37    pub total_bytes: i64,
38    /// Groups of duplicate weight files.
39    pub duplicates: Vec<DuplicateGroup>,
40    /// Non-fatal issues surfaced during the scan.
41    pub issues: Vec<String>,
42    /// Store kinds whose scan failed wholesale.
43    pub failed_kinds: Vec<SourceKind>,
44}
45
46impl DiscoverySummary {
47    /// A one-line, human-readable summary of what was found.
48    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
93/// Runs a set of scanners and reconciles their findings into a registry.
94pub struct DiscoveryService {
95    scanners: Vec<Box<dyn StoreScanner>>,
96    duplicate_threshold: i64,
97}
98
99impl DiscoveryService {
100    /// A service over `scanners`, using the default duplicate threshold.
101    pub fn new(scanners: Vec<Box<dyn StoreScanner>>) -> Self {
102        Self::with_threshold(scanners, DEFAULT_THRESHOLD)
103    }
104
105    /// A service with an explicit duplicate-detection size threshold.
106    pub fn with_threshold(scanners: Vec<Box<dyn StoreScanner>>, duplicate_threshold: i64) -> Self {
107        Self {
108            scanners,
109            duplicate_threshold,
110        }
111    }
112
113    /// Scan every store and reconcile the results into `registry`, returning a
114    /// summary. Records are updated/inserted; a scanned model no longer on disk
115    /// is marked missing; a model whose files moved carries its saved config to
116    /// the new record.
117    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
274/// Whether a record's weights are still on disk: its primary weight file if it
275/// names one (non-empty), otherwise the source path. A record with no weight
276/// path is treated as absent.
277fn 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
288/// The content fingerprint for a discovered model, reusing the existing record's
289/// fingerprint when the weight path and footprint are unchanged (to avoid
290/// re-hashing), and preserving it when the model has no weight path.
291fn 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
305/// Move a newly-found model's saved config (params, system prompt, alias) from a
306/// uniquely-matching missing record (same fingerprint and footprint) — the model
307/// moved on disk. Returns the ids of the claimed (orphaned) records to remove.
308fn 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        // Extract the unique orphan's config as owned values, then drop the
323        // iterator (it borrows `claimed`) before mutating `claimed` below.
324        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}