1use std::future::Future;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25
26use crate::registry::ModelUpgrade;
27use crate::schema::{ModelSchema, ModelSource, TrustTier};
28use crate::update_prefs::{UpdateChannel, UpdatePreferences};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum UpgradeSource {
34 Curated,
36 Upstream,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct UpgradeFinding {
44 pub from_id: String,
45 pub from_name: String,
46 pub to_id: String,
47 pub to_name: String,
48 pub reason: String,
50 pub trust_tier: TrustTier,
52 pub source: UpgradeSource,
53 pub target_pullable: bool,
55}
56
57impl UpgradeFinding {
58 fn from_curated(u: ModelUpgrade) -> Self {
59 UpgradeFinding {
60 from_id: u.from_id,
61 from_name: u.from_name,
62 to_id: u.to_id,
63 to_name: u.to_name,
64 reason: u.reason,
65 trust_tier: TrustTier::Curated,
66 source: UpgradeSource::Curated,
67 target_pullable: u.target_pullable,
68 }
69 }
70}
71
72pub trait UpstreamProbe {
76 fn newer_revision(&self, schema: &ModelSchema) -> impl Future<Output = Option<String>> + Send;
79}
80
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct UpgradeCache {
85 #[serde(default)]
87 pub checked_at_secs: u64,
88 #[serde(default)]
92 pub models_fingerprint: String,
93 #[serde(default)]
94 pub upstream: Vec<UpgradeFinding>,
95}
96
97impl UpgradeCache {
98 pub fn default_path() -> PathBuf {
99 std::env::var("HOME")
100 .map(PathBuf::from)
101 .unwrap_or_else(|_| PathBuf::from("."))
102 .join(".car")
103 .join("upgrade-cache.json")
104 }
105
106 pub fn load_from(path: &Path) -> Self {
107 std::fs::read_to_string(path)
108 .ok()
109 .and_then(|s| serde_json::from_str(&s).ok())
110 .unwrap_or_default()
111 }
112
113 pub fn save_to(&self, path: &Path) -> Result<(), String> {
114 if let Some(parent) = path.parent() {
115 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
116 }
117 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
118 std::fs::write(path, json).map_err(|e| e.to_string())
119 }
120
121 pub fn is_fresh(&self, now_secs: u64, ttl_secs: u64) -> bool {
123 self.checked_at_secs != 0 && now_secs.saturating_sub(self.checked_at_secs) < ttl_secs
124 }
125}
126
127pub const DEFAULT_TTL_SECS: u64 = 24 * 60 * 60;
129
130pub async fn detect_upgrades<P: UpstreamProbe>(
138 curated: Vec<ModelUpgrade>,
139 installed: &[&ModelSchema],
140 prefs: &UpdatePreferences,
141 probe: &P,
142 cache_path: &Path,
143 now_secs: u64,
144 ttl_secs: u64,
145) -> Vec<UpgradeFinding> {
146 let mut findings: Vec<UpgradeFinding> =
147 curated.into_iter().map(UpgradeFinding::from_curated).collect();
148
149 if prefs.channel == UpdateChannel::Latest && prefs.checks_enabled() {
150 let upstream = upstream_findings(installed, probe, cache_path, now_secs, ttl_secs).await;
151 for f in upstream {
153 if !findings.iter().any(|c| c.from_id == f.from_id) {
154 findings.push(f);
155 }
156 }
157 }
158
159 findings.sort_by(|a, b| a.from_id.cmp(&b.from_id).then(a.to_id.cmp(&b.to_id)));
160 findings.dedup_by(|a, b| a.from_id == b.from_id && a.to_id == b.to_id);
161 findings
162}
163
164async fn upstream_findings<P: UpstreamProbe>(
166 installed: &[&ModelSchema],
167 probe: &P,
168 cache_path: &Path,
169 now_secs: u64,
170 ttl_secs: u64,
171) -> Vec<UpgradeFinding> {
172 let fingerprint = installed_fingerprint(installed);
173 let cache = UpgradeCache::load_from(cache_path);
174 if cache.is_fresh(now_secs, ttl_secs) && cache.models_fingerprint == fingerprint {
177 return cache.upstream;
178 }
179
180 let mut found = Vec::new();
184 for schema in installed {
185 if !schema.available || repo_of(schema).is_none() {
187 continue;
188 }
189 if let Some(reason) = probe.newer_revision(schema).await {
190 found.push(UpgradeFinding {
191 from_id: schema.id.clone(),
192 from_name: schema.name.clone(),
193 to_id: schema.id.clone(),
196 to_name: schema.name.clone(),
197 reason,
198 trust_tier: TrustTier::Community,
199 source: UpgradeSource::Upstream,
200 target_pullable: matches!(
201 schema.source,
202 ModelSource::Local { .. } | ModelSource::Mlx { .. }
203 ),
204 });
205 }
206 }
207
208 let _ = UpgradeCache {
211 checked_at_secs: now_secs,
212 models_fingerprint: fingerprint,
213 upstream: found.clone(),
214 }
215 .save_to(cache_path);
216 found
217}
218
219fn installed_fingerprint(installed: &[&ModelSchema]) -> String {
222 use std::collections::hash_map::DefaultHasher;
223 use std::hash::{Hash, Hasher};
224 let mut ids: Vec<&str> = installed
225 .iter()
226 .filter(|m| m.available)
227 .map(|m| m.id.as_str())
228 .collect();
229 ids.sort_unstable();
230 let mut h = DefaultHasher::new();
231 ids.hash(&mut h);
232 format!("{:x}", h.finish())
233}
234
235fn repo_of(schema: &ModelSchema) -> Option<&str> {
237 match &schema.source {
238 ModelSource::Local { hf_repo, .. } | ModelSource::Mlx { hf_repo, .. } => Some(hf_repo),
239 _ => None,
240 }
241}
242
243pub struct HuggingFaceProbe {
249 client: reqwest::Client,
250}
251
252impl Default for HuggingFaceProbe {
253 fn default() -> Self {
254 Self::new()
255 }
256}
257
258impl HuggingFaceProbe {
259 pub fn new() -> Self {
260 let client = reqwest::Client::builder()
261 .timeout(std::time::Duration::from_secs(8))
262 .build()
263 .unwrap_or_default();
264 HuggingFaceProbe { client }
265 }
266
267 async fn remote_sha(&self, repo: &str) -> Option<String> {
268 let url = format!("https://huggingface.co/api/models/{repo}");
269 let resp = self.client.get(&url).send().await.ok()?;
270 if !resp.status().is_success() {
271 return None;
272 }
273 let json: serde_json::Value = resp.json().await.ok()?;
274 json.get("sha")?.as_str().map(|s| s.to_string())
275 }
276}
277
278impl UpstreamProbe for HuggingFaceProbe {
279 async fn newer_revision(&self, schema: &ModelSchema) -> Option<String> {
280 let repo = repo_of(schema)?;
281 let local_sha = local_main_sha(repo)?; let remote_sha = self.remote_sha(repo).await?; if remote_sha != local_sha {
284 Some(format!(
285 "A newer revision of {repo} is available on Hugging Face."
286 ))
287 } else {
288 None
289 }
290 }
291}
292
293fn local_main_sha(repo: &str) -> Option<String> {
295 let cache_root = std::env::var("HF_HOME")
296 .map(PathBuf::from)
297 .unwrap_or_else(|_| {
298 std::env::var("HOME")
299 .map(PathBuf::from)
300 .unwrap_or_else(|_| PathBuf::from("."))
301 .join(".cache")
302 .join("huggingface")
303 })
304 .join("hub");
305 let ref_path = cache_root
306 .join(format!("models--{}", repo.replace('/', "--")))
307 .join("refs")
308 .join("main");
309 std::fs::read_to_string(ref_path)
310 .ok()
311 .map(|s| s.trim().to_string())
312 .filter(|s| !s.is_empty())
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::schema::{CostModel, ModelCapability, PerformanceEnvelope};
319
320 fn local_schema(id: &str, available: bool) -> ModelSchema {
321 ModelSchema {
322 id: id.into(),
323 name: id.into(),
324 provider: "qwen".into(),
325 family: "qwen3".into(),
326 version: String::new(),
327 capabilities: vec![ModelCapability::Generate],
328 context_length: 8192,
329 param_count: "4B".into(),
330 quantization: None,
331 performance: PerformanceEnvelope::default(),
332 cost: CostModel::default(),
333 source: ModelSource::Local {
334 hf_repo: format!("org/{id}"),
335 hf_filename: "m.gguf".into(),
336 tokenizer_repo: format!("org/{id}"),
337 },
338 tags: vec![],
339 supported_params: vec![],
340 public_benchmarks: vec![],
341 trust_tier: TrustTier::Curated,
342 deprecated: false,
343 available,
344 }
345 }
346
347 struct FakeProbe {
348 newer: bool,
349 }
350 impl UpstreamProbe for FakeProbe {
351 async fn newer_revision(&self, _schema: &ModelSchema) -> Option<String> {
352 if self.newer {
353 Some("newer upstream".into())
354 } else {
355 None
356 }
357 }
358 }
359
360 struct NeverProbe;
362 impl UpstreamProbe for NeverProbe {
363 async fn newer_revision(&self, _schema: &ModelSchema) -> Option<String> {
364 panic!("probe must not be called");
365 }
366 }
367
368 fn tmp_cache(tag: &str) -> PathBuf {
369 std::env::temp_dir().join(format!("car-upgrade-{tag}-{}.json", std::process::id()))
370 }
371
372 #[tokio::test]
373 async fn stable_channel_is_curated_only_and_never_probes() {
374 let prefs = UpdatePreferences::default(); let installed = local_schema("qwen3-4b", true);
376 let cache = tmp_cache("stable");
377 let findings = detect_upgrades(
378 vec![],
379 &[&installed],
380 &prefs,
381 &NeverProbe, &cache,
383 1000,
384 DEFAULT_TTL_SECS,
385 )
386 .await;
387 assert!(findings.is_empty());
388 let _ = std::fs::remove_file(&cache);
389 }
390
391 #[tokio::test]
392 async fn latest_channel_adds_upstream_findings() {
393 let prefs = UpdatePreferences {
394 channel: UpdateChannel::Latest,
395 ..Default::default()
396 };
397 let installed = local_schema("qwen3-4b", true);
398 let cache = tmp_cache("latest");
399 let _ = std::fs::remove_file(&cache);
400 let findings = detect_upgrades(
401 vec![],
402 &[&installed],
403 &prefs,
404 &FakeProbe { newer: true },
405 &cache,
406 1000,
407 DEFAULT_TTL_SECS,
408 )
409 .await;
410 assert_eq!(findings.len(), 1);
411 assert_eq!(findings[0].source, UpgradeSource::Upstream);
412 assert_eq!(findings[0].trust_tier, TrustTier::Community);
413 let _ = std::fs::remove_file(&cache);
414 }
415
416 #[tokio::test]
417 async fn uninstalled_models_are_not_probed() {
418 let prefs = UpdatePreferences {
419 channel: UpdateChannel::Latest,
420 ..Default::default()
421 };
422 let installed = local_schema("qwen3-4b", false); let cache = tmp_cache("uninstalled");
424 let _ = std::fs::remove_file(&cache);
425 let findings = detect_upgrades(
426 vec![],
427 &[&installed],
428 &prefs,
429 &NeverProbe, &cache,
431 1000,
432 DEFAULT_TTL_SECS,
433 )
434 .await;
435 assert!(findings.is_empty());
436 let _ = std::fs::remove_file(&cache);
437 }
438
439 #[tokio::test]
440 async fn fresh_cache_is_served_without_probing() {
441 let prefs = UpdatePreferences {
442 channel: UpdateChannel::Latest,
443 ..Default::default()
444 };
445 let installed = local_schema("qwen3-4b", true);
446 let cache = tmp_cache("fresh");
447 UpgradeCache {
450 checked_at_secs: 1000,
451 models_fingerprint: installed_fingerprint(&[&installed]),
452 upstream: vec![UpgradeFinding {
453 from_id: "qwen3-4b".into(),
454 from_name: "qwen3-4b".into(),
455 to_id: "qwen3-4b".into(),
456 to_name: "qwen3-4b".into(),
457 reason: "cached".into(),
458 trust_tier: TrustTier::Community,
459 source: UpgradeSource::Upstream,
460 target_pullable: true,
461 }],
462 }
463 .save_to(&cache)
464 .unwrap();
465 let findings = detect_upgrades(
467 vec![],
468 &[&installed],
469 &prefs,
470 &NeverProbe,
471 &cache,
472 1500,
473 DEFAULT_TTL_SECS,
474 )
475 .await;
476 assert_eq!(findings.len(), 1);
477 assert_eq!(findings[0].reason, "cached");
478 let _ = std::fs::remove_file(&cache);
479 }
480
481 #[tokio::test]
482 async fn fresh_cache_for_a_different_model_set_is_invalidated() {
483 let prefs = UpdatePreferences {
486 channel: UpdateChannel::Latest,
487 ..Default::default()
488 };
489 let installed = local_schema("qwen3-8b", true); let cache = tmp_cache("fingerprint");
491 UpgradeCache {
492 checked_at_secs: 1000,
493 models_fingerprint: "stale-different-set".into(),
494 upstream: vec![],
495 }
496 .save_to(&cache)
497 .unwrap();
498 let findings = detect_upgrades(
499 vec![],
500 &[&installed],
501 &prefs,
502 &FakeProbe { newer: true }, &cache,
504 1500,
505 DEFAULT_TTL_SECS,
506 )
507 .await;
508 assert_eq!(findings.len(), 1, "stale-fingerprint cache must re-probe");
509 let _ = std::fs::remove_file(&cache);
510 }
511
512 #[tokio::test]
513 async fn curated_wins_over_upstream_for_same_model() {
514 let prefs = UpdatePreferences {
515 channel: UpdateChannel::Latest,
516 ..Default::default()
517 };
518 let installed = local_schema("qwen3-4b", true);
519 let cache = tmp_cache("dedup");
520 let _ = std::fs::remove_file(&cache);
521 let curated = vec![ModelUpgrade {
522 from_id: "qwen3-4b".into(),
523 from_name: "qwen3-4b".into(),
524 to_id: "qwen3-8b".into(),
525 to_name: "qwen3-8b".into(),
526 reason: "curated replacement".into(),
527 target_runtime: None,
528 target_runtime_requirement: None,
529 minimum_runtimes: vec![],
530 target_available: true,
531 target_pullable: true,
532 remove_old_supported: true,
533 }];
534 let findings = detect_upgrades(
535 curated,
536 &[&installed],
537 &prefs,
538 &FakeProbe { newer: true },
539 &cache,
540 1000,
541 DEFAULT_TTL_SECS,
542 )
543 .await;
544 assert_eq!(findings.len(), 1);
546 assert_eq!(findings[0].source, UpgradeSource::Curated);
547 let _ = std::fs::remove_file(&cache);
548 }
549}