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 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
270/// Whether a record's weights are still on disk: its primary weight file if it
271/// names one (non-empty), otherwise the source path. A record with no weight
272/// path is treated as absent.
273fn 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
284/// The content fingerprint for a discovered model, reusing the existing record's
285/// fingerprint when the weight path and footprint are unchanged (to avoid
286/// re-hashing), and preserving it when the model has no weight path.
287fn 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
301/// Move a newly-found model's saved config (params, system prompt, alias) from a
302/// uniquely-matching missing record (same fingerprint and footprint) — the model
303/// moved on disk. Returns the ids of the claimed (orphaned) records to remove.
304fn 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        // Extract the unique orphan's config as owned values, then drop the
319        // iterator (it borrows `claimed`) before mutating `claimed` below.
320        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}