Skip to main content

navi_core/registry/
update.rs

1//! Registry update orchestration.
2//!
3//! Implements the full update lifecycle requested by the NAVI registry design:
4//!
5//! 1. Load from local cache or embedded snapshot (never block startup).
6//! 2. Check for remote updates in the background, respecting a 24h + jitter interval.
7//! 3. Diff-fetch only changed provider files.
8//! 4. Validate schema and SHA-256 hashes before applying.
9//! 5. Roll back to the previous registry on failure.
10//!
11//! Public API shape matches the requested functions:
12//! `load_registry`, `load_embedded_registry`, `load_cached_registry`,
13//! `should_check_registry_update`, `check_registry_manifest`, `download_registry_updates`,
14//! `validate_registry_schema`, `validate_registry_hashes`, `apply_registry_update_atomically`,
15//! `save_registry_metadata`.
16
17use anyhow::{Context, Result};
18use jsonschema::validator_for;
19use sha2::{Digest, Sha256};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use crate::config::types::{ProviderConfig, RegistryConfig};
23
24use super::embedded::{
25    embedded_manifest, embedded_provider_schema, embedded_providers,
26    embedded_transcription_providers,
27};
28use super::store::RegistryStore;
29use super::types::{RegistryManifest, RegistryProvider, RegistryTranscriptionProvider};
30
31/// Trait abstracting registry fetching so the update flow can be tested without network.
32#[allow(async_fn_in_trait)]
33pub trait RegistryFetcherTrait {
34    async fn fetch_manifest(&self) -> Result<RegistryManifest>;
35    async fn fetch_provider(
36        &self,
37        provider_id: &str,
38        manifest: &RegistryManifest,
39    ) -> Result<RegistryProvider>;
40    async fn fetch_transcription_provider(
41        &self,
42        provider_id: &str,
43        manifest: &RegistryManifest,
44    ) -> Result<RegistryTranscriptionProvider>;
45}
46
47impl RegistryFetcherTrait for super::RegistryFetcher {
48    async fn fetch_manifest(&self) -> Result<RegistryManifest> {
49        self.fetch_manifest().await
50    }
51
52    async fn fetch_provider(
53        &self,
54        provider_id: &str,
55        manifest: &RegistryManifest,
56    ) -> Result<RegistryProvider> {
57        self.fetch_provider(provider_id, manifest).await
58    }
59
60    async fn fetch_transcription_provider(
61        &self,
62        provider_id: &str,
63        manifest: &RegistryManifest,
64    ) -> Result<RegistryTranscriptionProvider> {
65        self.fetch_transcription_provider(provider_id, manifest)
66            .await
67    }
68}
69
70/// Metadata keys stored in `registry_meta`.
71const META_LAST_CHECK: &str = "last_registry_check";
72const META_LAST_SUCCESS: &str = "last_registry_success";
73const META_MANIFEST_JSON: &str = "registry_manifest_json";
74/// Fingerprint of the last successful full rehydrate against the canonical model catalog.
75/// Avoids re-running hundreds of UPDATEs on every `load_registry` / catalog read.
76const META_REHYDRATE_FP: &str = "canonical_models_rehydrate_fp";
77
78/// Result of loading the registry for immediate use.
79#[derive(Debug, Clone)]
80pub struct LoadedRegistry {
81    pub manifest: RegistryManifest,
82    pub providers: Vec<ProviderConfig>,
83    pub source: RegistrySource,
84}
85
86/// Where the currently-active registry came from.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum RegistrySource {
89    /// Loaded from the local SQLite cache.
90    Cache,
91    /// Loaded from the binary's embedded snapshot.
92    Embedded,
93    /// Fallback minimal hardcoded registry.
94    MinimalFallback,
95}
96
97/// Loads the registry for use, preferring cache, then embedded snapshot, then a minimal fallback.
98///
99/// This is the entry point for startup. It never blocks on network.
100/// After loading the cache, it upserts any providers whose embedded snapshot
101/// hash differs from the cached version. This ensures new models and pricing
102/// updates shipped with the binary are merged into the local cache without
103/// waiting for a remote sync.
104pub fn load_registry(store: &RegistryStore) -> LoadedRegistry {
105    // Try cache first.
106    if let Some(loaded) = load_cached_registry(store) {
107        // Merge in any providers from the embedded snapshot that are newer
108        // (different SHA-256) than what's in the cache.
109        let merged = merge_embedded_provider_updates(store);
110
111        // Correct stale context/effort rows left by API-sync sibling inheritance.
112        // Skip when the catalog fingerprint is unchanged — rehydrate is O(models)
113        // and used to run on every provider_catalog() call.
114        maybe_rehydrate_from_catalog(store, merged);
115
116        // Reload only when the store may have changed.
117        if merged {
118            if let Some(reloaded) = load_cached_registry(store) {
119                tracing::info!(
120                    version = reloaded.manifest.version,
121                    providers = reloaded.providers.len(),
122                    "loaded registry from local cache (after embedded merge)"
123                );
124                return reloaded;
125            }
126        }
127        tracing::debug!(
128            version = loaded.manifest.version,
129            providers = loaded.providers.len(),
130            "loaded registry from local cache"
131        );
132        return loaded;
133    }
134
135    if let Some(loaded) = load_embedded_registry() {
136        tracing::info!(
137            version = loaded.manifest.version,
138            providers = loaded.providers.len(),
139            "loaded registry from embedded snapshot"
140        );
141        // Seed the cache from the embedded snapshot so next startup hits the cache.
142        if let Err(err) = seed_cache_from_embedded(store) {
143            tracing::warn!(error = %err, "failed to seed cache from embedded snapshot");
144        }
145        return loaded;
146    }
147
148    tracing::error!("failed to load embedded registry snapshot, using minimal fallback");
149    LoadedRegistry {
150        manifest: RegistryManifest {
151            version: 0,
152            updated_at: "1970-01-01T00:00:00Z".to_string(),
153            providers: std::collections::HashMap::new(),
154            transcription_providers: Default::default(),
155            coverage: Default::default(),
156            models: Default::default(),
157        },
158        providers: minimal_fallback_providers(),
159        source: RegistrySource::MinimalFallback,
160    }
161}
162
163/// Upserts providers from the embedded snapshot whose SHA-256 hash differs
164/// from the cached version. This merges new models and pricing updates into
165/// the local cache without overwriting providers that are newer in the cache
166/// (e.g. from a remote sync).
167///
168/// Also **prunes** providers (and transcription providers) that exist only in
169/// the local cache and are no longer in the embedded catalog. Without this,
170/// deleted registry entries (e.g. removed third-party gateways) stick forever
171/// when the cache already has the same manifest version as the snapshot —
172/// remote sync early-returns on version match and never runs
173/// `delete_providers_not_in`.
174///
175/// Returns `true` when the store was mutated (caller should reload / rehydrate).
176fn merge_embedded_provider_updates(store: &RegistryStore) -> bool {
177    let embedded_manifest = match embedded_manifest() {
178        Ok(m) => m,
179        Err(_) => return false,
180    };
181    let embedded_providers = match embedded_providers() {
182        Ok(p) => p,
183        Err(_) => return false,
184    };
185
186    let mut updated = 0;
187    for ep in &embedded_providers {
188        let id = &ep.id;
189        let embedded_sha = embedded_manifest
190            .providers
191            .get(id)
192            .map(|e| e.sha256.as_str());
193        let cached_sha = store.provider_sha256(id).ok().flatten();
194
195        let needs_update = match (&cached_sha, embedded_sha) {
196            // API/model sync marker: keep LOCAL_API_SYNC_SHA, but still
197            // union-merge when the embedded catalog for this provider changed
198            // so context/efforts can be corrected without wiping API-only SKUs.
199            (Some(cached), Some(embedded)) if cached == crate::registry::LOCAL_API_SYNC_SHA => {
200                let key = format!("api_sync_catalog_sha:{id}");
201                store.meta_get(&key).ok().flatten().as_deref() != Some(embedded)
202            }
203            (Some(cached), Some(embedded)) => cached != embedded,
204            // Missing sha usually means a partial/legacy API sync. Only seed
205            // when the cache has zero models for this provider.
206            (None, Some(_)) => store
207                .load_provider_models(id)
208                .map(|m| m.is_empty())
209                .unwrap_or(true),
210            (None, None) => false,
211            (Some(_), None) => false,
212        };
213
214        if !needs_update {
215            continue;
216        }
217
218        let preserve_api_sync = cached_sha.as_deref() == Some(crate::registry::LOCAL_API_SYNC_SHA);
219        let sha_to_write = if preserve_api_sync {
220            Some(crate::registry::LOCAL_API_SYNC_SHA)
221        } else {
222            embedded_sha
223        };
224
225        // Prefer union-merge so a smaller embedded snapshot cannot wipe
226        // models discovered via provider APIs (OpenRouter etc.).
227        if let Err(err) = store.upsert_provider_union_models(ep, sha_to_write) {
228            tracing::warn!(
229                provider = id,
230                error = %err,
231                "failed to upsert embedded provider update"
232            );
233        } else {
234            if preserve_api_sync && let Some(embedded) = embedded_sha {
235                let key = format!("api_sync_catalog_sha:{id}");
236                let _ = store.meta_set(&key, embedded);
237            }
238            updated += 1;
239        }
240    }
241
242    // Drop providers removed from the embedded catalog (including ones marked
243    // local-api-sync — the upstream entry is gone, so the cache row is stale).
244    let keep: std::collections::HashSet<&str> = embedded_manifest
245        .providers
246        .keys()
247        .map(|s| s.as_str())
248        .collect();
249    if let Err(err) = store.delete_providers_not_in(&keep) {
250        tracing::warn!(error = %err, "failed to prune stale providers against embedded catalog");
251    }
252
253    // Merge embedded transcription / dictation providers by sha256.
254    let mut tx_updated = 0;
255    if let Ok(tx_providers) = embedded_transcription_providers() {
256        for ep in &tx_providers {
257            let embedded_sha = embedded_manifest
258                .transcription_providers
259                .get(&ep.id)
260                .map(|e| e.sha256.as_str());
261            let cached_sha = store.transcription_provider_sha256(&ep.id).ok().flatten();
262            let needs_update = match (&cached_sha, embedded_sha) {
263                (Some(cached), Some(embedded)) => cached != embedded,
264                (None, Some(_)) => true,
265                _ => false,
266            };
267            if needs_update {
268                if let Err(err) = store.upsert_transcription_provider(ep, embedded_sha) {
269                    tracing::warn!(
270                        provider = %ep.id,
271                        error = %err,
272                        "failed to upsert embedded transcription provider"
273                    );
274                } else {
275                    tx_updated += 1;
276                }
277            }
278        }
279    }
280
281    let tx_keep: std::collections::HashSet<&str> = embedded_manifest
282        .transcription_providers
283        .keys()
284        .map(|s| s.as_str())
285        .collect();
286    if let Err(err) = store.delete_transcription_providers_not_in(&tx_keep) {
287        tracing::warn!(
288            error = %err,
289            "failed to prune stale transcription providers against embedded catalog"
290        );
291    }
292
293    if updated > 0 || tx_updated > 0 {
294        tracing::info!(
295            updated_providers = updated,
296            updated_transcription = tx_updated,
297            "merged embedded provider updates into local cache"
298        );
299        // Update manifest metadata to reflect merged state.
300        let _ = store.save_manifest_meta(&embedded_manifest);
301        let _ = save_registry_metadata(store, &embedded_manifest, None, None);
302        return true;
303    }
304    false
305}
306
307/// Full model rehydrate is expensive; run only when the catalog fingerprint changes
308/// or when the store just mutated (`force`).
309fn maybe_rehydrate_from_catalog(store: &RegistryStore, force: bool) {
310    let fingerprint = catalog_rehydrate_fingerprint(store);
311    if !force {
312        if let Ok(Some(prev)) = store.meta_get(META_REHYDRATE_FP) {
313            if prev == fingerprint {
314                return;
315            }
316        }
317    }
318    match store.rehydrate_provider_models_from_catalog() {
319        Ok(_) => {
320            let _ = store.meta_set(META_REHYDRATE_FP, &fingerprint);
321        }
322        Err(err) => {
323            tracing::warn!(error = %err, "failed to rehydrate models from canonical catalog");
324        }
325    }
326}
327
328fn catalog_rehydrate_fingerprint(store: &RegistryStore) -> String {
329    // Prefer stored manifest version + model count; fall back to embedded.
330    let version = store
331        .manifest_version()
332        .ok()
333        .flatten()
334        .or_else(|| embedded_manifest().ok().map(|m| m.version))
335        .unwrap_or(0);
336    let models = store
337        .load_canonical_model_catalog()
338        .map(|c| c.len())
339        .unwrap_or(0);
340    format!("v{version}:m{models}")
341}
342
343/// Loads the registry from the local SQLite cache if it is non-empty.
344pub fn load_cached_registry(store: &RegistryStore) -> Option<LoadedRegistry> {
345    let manifest = store_stored_manifest(store).ok().flatten()?;
346    let providers = store.load_all_providers().ok()?;
347    if providers.is_empty() {
348        return None;
349    }
350    Some(LoadedRegistry {
351        manifest,
352        providers,
353        source: RegistrySource::Cache,
354    })
355}
356
357/// Loads the registry from the binary's embedded snapshot.
358pub fn load_embedded_registry() -> Option<LoadedRegistry> {
359    let manifest = embedded_manifest().ok()?;
360    let providers = embedded_providers().ok()?;
361    Some(LoadedRegistry {
362        manifest,
363        providers: providers
364            .into_iter()
365            .map(super::store::registry_provider_to_config)
366            .collect(),
367        source: RegistrySource::Embedded,
368    })
369}
370
371/// Seeds the local cache from the embedded snapshot.
372fn seed_cache_from_embedded(store: &RegistryStore) -> Result<()> {
373    let manifest = embedded_manifest().context("failed to parse embedded manifest")?;
374    let providers = embedded_providers().context("failed to parse embedded providers")?;
375    store.replace_all(&providers)?;
376    if let Ok(tx_providers) = embedded_transcription_providers() {
377        for p in &tx_providers {
378            let sha = manifest
379                .transcription_providers
380                .get(&p.id)
381                .map(|e| e.sha256.as_str());
382            store.upsert_transcription_provider(p, sha)?;
383        }
384    }
385    store.save_manifest_meta(&manifest)?;
386    save_registry_metadata(
387        store,
388        &manifest,
389        Some(current_timestamp_secs()),
390        Some(current_timestamp_secs()),
391    )?;
392    Ok(())
393}
394
395/// Minimal hardcoded fallback used only if the embedded snapshot itself fails to parse.
396fn minimal_fallback_providers() -> Vec<ProviderConfig> {
397    use crate::config::types::{ModelTaskSize, ProviderModelConfig};
398
399    vec![ProviderConfig {
400        id: "openai".to_string(),
401        label: "OpenAI".to_string(),
402        description: "OpenAI API key required".to_string(),
403        kind: crate::config::types::ProviderKind::OpenAiResponses,
404        api_key_env: "OPENAI_API_KEY".to_string(),
405        base_url: Some("https://api.openai.com/v1".to_string()),
406        models: vec![ProviderModelConfig {
407            name: "gpt-5.1".to_string(),
408            task_size: Some(ModelTaskSize::Large),
409            context_window_tokens: Some(1_000_000),
410            max_output_tokens: None,
411            recommended_temperature: None,
412            supports_thinking: None,
413            supports_images: None,
414            supports_audio: None,
415            supports_video: None,
416            supports_documents: None,
417            tool_prompt_manifest: None,
418            pricing_input_per_1m: None,
419            pricing_output_per_1m: None,
420            reasoning_levels: Vec::new(),
421            default_reasoning_effort: None,
422        }],
423        request_options: crate::config::providers::default_request_options_for("openai"),
424        ..Default::default()
425    }]
426}
427
428/// Returns whether enough time has passed (plus jitter) to check for a remote update.
429pub fn should_check_registry_update(store: &RegistryStore, config: &RegistryConfig) -> bool {
430    if !config.update_enabled {
431        return false;
432    }
433
434    let last_check = store
435        .meta_get(META_LAST_CHECK)
436        .ok()
437        .flatten()
438        .and_then(|s| s.parse::<u64>().ok())
439        .unwrap_or(0);
440
441    let now = current_timestamp_secs();
442    let elapsed = now.saturating_sub(last_check);
443    let interval = config.check_interval_hours.saturating_mul(3600);
444
445    elapsed >= interval
446}
447
448/// Computes the jittered interval in seconds for the next update check.
449pub fn registry_check_interval_with_jitter(config: &RegistryConfig) -> u64 {
450    let base = config.check_interval_hours.saturating_mul(3600);
451    let jitter = if config.check_jitter_hours > 0 {
452        fastrand::u64(0..=config.check_jitter_hours.saturating_mul(3600))
453    } else {
454        0
455    };
456    base.saturating_add(jitter)
457}
458
459/// Fetches and validates the remote manifest, returning it if it differs from the local one.
460///
461/// Returns `Ok(None)` if the remote manifest is the same as the local one.
462/// Returns `Err(...)` on network or parse failures (caller should fall back).
463pub async fn check_registry_manifest(
464    store: &RegistryStore,
465    fetcher: &impl RegistryFetcherTrait,
466    config: &RegistryConfig,
467) -> Result<Option<RegistryManifest>> {
468    let local = store_stored_manifest(store)
469        .ok()
470        .flatten()
471        .unwrap_or_else(|| {
472            embedded_manifest().unwrap_or_else(|_| RegistryManifest {
473                version: 0,
474                updated_at: "1970-01-01T00:00:00Z".to_string(),
475                providers: std::collections::HashMap::new(),
476                transcription_providers: Default::default(),
477                coverage: Default::default(),
478                models: Default::default(),
479            })
480        });
481
482    let manifest = fetch_manifest_with_retry(fetcher, config).await?;
483
484    // If the remote version and hash set are identical to local, nothing changed.
485    if manifest.version == local.version && provider_hashes_equal(&manifest, &local) {
486        save_registry_metadata(store, &manifest, Some(current_timestamp_secs()), None)?;
487        tracing::debug!(
488            version = manifest.version,
489            "remote manifest unchanged, no update needed"
490        );
491        return Ok(None);
492    }
493
494    Ok(Some(manifest))
495}
496
497/// Downloads only the provider files that changed, validating schema and hashes.
498///
499/// Returns the validated providers ready to be applied.
500pub async fn download_registry_updates(
501    store: &RegistryStore,
502    fetcher: &impl RegistryFetcherTrait,
503    manifest: &RegistryManifest,
504    config: &RegistryConfig,
505) -> Result<Vec<RegistryProvider>> {
506    let mut to_fetch = Vec::new();
507
508    for (provider_id, entry) in &manifest.providers {
509        let cached_sha = store.provider_sha256(provider_id)?;
510        if cached_sha.as_deref() != Some(&entry.sha256) {
511            to_fetch.push(provider_id.as_str());
512        }
513    }
514
515    if to_fetch.is_empty() {
516        tracing::debug!("all provider hashes match, nothing to download");
517        return Ok(Vec::new());
518    }
519
520    tracing::info!(
521        changed = to_fetch.len(),
522        total = manifest.providers.len(),
523        "downloading changed registry providers"
524    );
525
526    let schema = embedded_provider_schema();
527    let mut providers = Vec::with_capacity(to_fetch.len());
528
529    // Parallel download of changed providers (was sequential and dominated sync time).
530    // Cap concurrency so we don't open dozens of HTTPS streams at once.
531    const MAX_PARALLEL: usize = 8;
532    for chunk in to_fetch.chunks(MAX_PARALLEL) {
533        let fetch_futs: Vec<_> = chunk
534            .iter()
535            .map(|provider_id| {
536                let pid = (*provider_id).to_string();
537                async move {
538                    let provider =
539                        fetch_provider_with_retry(fetcher, pid.as_str(), manifest, config).await?;
540                    Ok::<_, anyhow::Error>(provider)
541                }
542            })
543            .collect();
544        let fetched = futures_util::future::try_join_all(fetch_futs).await?;
545        for provider in fetched {
546            validate_registry_schema(&provider, schema)?;
547            providers.push(provider);
548        }
549    }
550
551    validate_registry_hashes(&providers, manifest)?;
552
553    Ok(providers)
554}
555
556/// Validates a provider against the JSON schema.
557pub fn validate_registry_schema(
558    provider: &RegistryProvider,
559    schema_json: Option<&str>,
560) -> Result<()> {
561    if let Some(schema_json) = schema_json {
562        let schema_value: serde_json::Value = serde_json::from_str(schema_json)
563            .context("failed to parse embedded provider schema")?;
564        let validator = validator_for(&schema_value)
565            .map_err(|e| anyhow::anyhow!("failed to build schema validator: {e}"))?;
566        let provider_value = serde_json::to_value(provider)
567            .context("failed to serialize provider for validation")?;
568        if let Err(error) = validator.validate(&provider_value) {
569            anyhow::bail!("provider schema validation failed: {}", error);
570        }
571    }
572    Ok(())
573}
574
575/// Validates the SHA-256 hashes of a set of downloaded providers against the manifest.
576pub fn validate_registry_hashes(
577    providers: &[RegistryProvider],
578    manifest: &RegistryManifest,
579) -> Result<()> {
580    for provider in providers {
581        let entry = manifest
582            .providers
583            .get(&provider.id)
584            .with_context(|| format!("provider '{}' not in manifest", provider.id))?;
585        let provider_json = serde_json::to_string(provider).with_context(|| {
586            format!(
587                "failed to serialize provider '{}' for hash check",
588                provider.id
589            )
590        })?;
591        let hash = hex::encode(Sha256::digest(provider_json.as_bytes()));
592        if hash != entry.sha256 {
593            anyhow::bail!(
594                "provider '{}' hash mismatch: expected {}, got {}",
595                provider.id,
596                entry.sha256,
597                hash
598            );
599        }
600    }
601    Ok(())
602}
603
604/// Applies a validated update atomically: store new providers, delete removed ones, and save metadata.
605///
606/// Model lists are **union-merged** with any local cache so a smaller remote
607/// catalog cannot wipe models previously discovered via `sync models`.
608///
609/// On failure, the previous registry contents remain intact (this function uses explicit transactions).
610pub fn apply_registry_update_atomically(
611    store: &RegistryStore,
612    manifest: &RegistryManifest,
613    providers: &[RegistryProvider],
614) -> Result<()> {
615    apply_registry_update_atomically_with_transcription(store, manifest, providers, &[])
616}
617
618/// Like [`apply_registry_update_atomically`], also applying remote transcription providers.
619pub fn apply_registry_update_atomically_with_transcription(
620    store: &RegistryStore,
621    manifest: &RegistryManifest,
622    providers: &[RegistryProvider],
623    transcription: &[RegistryTranscriptionProvider],
624) -> Result<()> {
625    let keep: std::collections::HashSet<&str> =
626        manifest.providers.keys().map(|s| s.as_str()).collect();
627
628    for provider in providers {
629        let sha = manifest
630            .providers
631            .get(&provider.id)
632            .map(|e| e.sha256.as_str());
633        store.upsert_provider_union_models(provider, sha)?;
634    }
635
636    store.delete_providers_not_in(&keep)?;
637
638    let tx_keep: std::collections::HashSet<&str> = manifest
639        .transcription_providers
640        .keys()
641        .map(|s| s.as_str())
642        .collect();
643    for provider in transcription {
644        let sha = manifest
645            .transcription_providers
646            .get(&provider.id)
647            .map(|e| e.sha256.as_str());
648        store.upsert_transcription_provider(provider, sha)?;
649    }
650    store.delete_transcription_providers_not_in(&tx_keep)?;
651
652    store.save_manifest_meta(manifest)?;
653    // Clear rehydrate fingerprint so the next load reapplies catalog metadata.
654    let _ = store.meta_set(META_REHYDRATE_FP, "");
655    crate::config::providers::invalidate_registry_catalog_cache();
656
657    Ok(())
658}
659
660/// Saves registry metadata: last check, last success, and the manifest JSON.
661pub fn save_registry_metadata(
662    store: &RegistryStore,
663    manifest: &RegistryManifest,
664    last_check: Option<u64>,
665    last_success: Option<u64>,
666) -> Result<()> {
667    if let Some(ts) = last_check {
668        store.meta_set(META_LAST_CHECK, &ts.to_string())?;
669    }
670    if let Some(ts) = last_success {
671        store.meta_set(META_LAST_SUCCESS, &ts.to_string())?;
672    }
673    let manifest_json =
674        serde_json::to_string(manifest).context("failed to serialize manifest metadata")?;
675    store.meta_set(META_MANIFEST_JSON, &manifest_json)?;
676    Ok(())
677}
678
679/// Full background update check. Returns `true` if the cache was updated.
680///
681/// This is safe to run from a background task: all failures are logged and swallowed.
682pub async fn run_registry_update_check(
683    store: &RegistryStore,
684    fetcher: &impl RegistryFetcherTrait,
685    config: &RegistryConfig,
686) -> bool {
687    if !config.update_enabled {
688        return false;
689    }
690
691    // Record that we attempted a check.
692    let now = current_timestamp_secs();
693    if let Err(err) = save_registry_metadata(
694        store,
695        &current_stored_manifest_or_embedded(store),
696        Some(now),
697        None,
698    ) {
699        tracing::warn!(error = %err, "failed to save registry check timestamp");
700    }
701
702    let manifest = match check_registry_manifest(store, fetcher, config).await {
703        Ok(Some(m)) => m,
704        Ok(None) => {
705            tracing::debug!("registry update check: no changes");
706            return false;
707        }
708        Err(err) => {
709            tracing::warn!(error = %err, "registry manifest check failed, keeping existing registry");
710            return false;
711        }
712    };
713
714    let providers = match download_registry_updates(store, fetcher, &manifest, config).await {
715        Ok(p) => p,
716        Err(err) => {
717            tracing::warn!(error = %err, "registry update download failed, keeping existing registry");
718            return false;
719        }
720    };
721
722    let transcription =
723        match download_transcription_updates(store, fetcher, &manifest, config).await {
724            Ok(p) => p,
725            Err(err) => {
726                tracing::warn!(
727                    error = %err,
728                    "transcription registry update download failed, keeping existing registry"
729                );
730                return false;
731            }
732        };
733
734    if let Err(err) = apply_registry_update_atomically_with_transcription(
735        store,
736        &manifest,
737        &providers,
738        &transcription,
739    ) {
740        tracing::warn!(error = %err, "failed to apply registry update, keeping previous registry");
741        return false;
742    }
743
744    if let Err(err) = save_registry_metadata(store, &manifest, Some(now), Some(now)) {
745        tracing::warn!(error = %err, "failed to save registry success timestamp");
746    }
747
748    tracing::info!(
749        version = manifest.version,
750        providers = manifest.providers.len(),
751        transcription_providers = manifest.transcription_providers.len(),
752        "registry cache updated from remote"
753    );
754
755    true
756}
757
758// ── Helpers ─────────────────────────────────────────────────────────────────
759
760fn store_stored_manifest(store: &RegistryStore) -> Result<Option<RegistryManifest>> {
761    match store.meta_get(META_MANIFEST_JSON)? {
762        Some(json) => Ok(Some(
763            serde_json::from_str(&json).context("failed to parse stored manifest metadata")?,
764        )),
765        None => Ok(None),
766    }
767}
768
769fn current_stored_manifest_or_embedded(store: &RegistryStore) -> RegistryManifest {
770    store_stored_manifest(store)
771        .ok()
772        .flatten()
773        .unwrap_or_else(|| {
774            embedded_manifest().unwrap_or_else(|_| RegistryManifest {
775                version: 0,
776                updated_at: "1970-01-01T00:00:00Z".to_string(),
777                providers: std::collections::HashMap::new(),
778                transcription_providers: Default::default(),
779                coverage: Default::default(),
780                models: Default::default(),
781            })
782        })
783}
784
785fn provider_hashes_equal(a: &RegistryManifest, b: &RegistryManifest) -> bool {
786    if a.providers.len() != b.providers.len() {
787        return false;
788    }
789    if a.transcription_providers.len() != b.transcription_providers.len() {
790        return false;
791    }
792    let llm_ok = a.providers.iter().all(|(id, entry)| {
793        b.providers
794            .get(id)
795            .map(|other| entry.sha256 == other.sha256)
796            .unwrap_or(false)
797    });
798    if !llm_ok {
799        return false;
800    }
801    a.transcription_providers.iter().all(|(id, entry)| {
802        b.transcription_providers
803            .get(id)
804            .map(|other| entry.sha256 == other.sha256)
805            .unwrap_or(false)
806    })
807}
808
809/// Downloads only the transcription provider files that changed.
810pub async fn download_transcription_updates(
811    store: &RegistryStore,
812    fetcher: &impl RegistryFetcherTrait,
813    manifest: &RegistryManifest,
814    config: &RegistryConfig,
815) -> Result<Vec<RegistryTranscriptionProvider>> {
816    let mut to_fetch = Vec::new();
817    for (provider_id, entry) in &manifest.transcription_providers {
818        let cached_sha = store.transcription_provider_sha256(provider_id)?;
819        if cached_sha.as_deref() != Some(&entry.sha256) {
820            to_fetch.push(provider_id.as_str());
821        }
822    }
823
824    if to_fetch.is_empty() {
825        return Ok(Vec::new());
826    }
827
828    tracing::info!(
829        changed = to_fetch.len(),
830        total = manifest.transcription_providers.len(),
831        "downloading changed transcription providers"
832    );
833
834    let mut providers = Vec::with_capacity(to_fetch.len());
835    for provider_id in &to_fetch {
836        let provider =
837            fetch_transcription_with_retry(fetcher, provider_id, manifest, config).await?;
838        providers.push(provider);
839    }
840    Ok(providers)
841}
842
843async fn fetch_transcription_with_retry(
844    fetcher: &impl RegistryFetcherTrait,
845    provider_id: &str,
846    manifest: &RegistryManifest,
847    config: &RegistryConfig,
848) -> Result<RegistryTranscriptionProvider> {
849    let mut last_err = None;
850    let attempts = config.max_retries.saturating_add(1).max(1);
851    for attempt in 1..=attempts {
852        match fetcher
853            .fetch_transcription_provider(provider_id, manifest)
854            .await
855        {
856            Ok(p) => return Ok(p),
857            Err(err) => {
858                tracing::debug!(
859                    attempt,
860                    provider = provider_id,
861                    error = %err,
862                    "transcription provider fetch failed"
863                );
864                last_err = Some(err);
865                if attempt < attempts {
866                    tokio::time::sleep(std::time::Duration::from_millis(500 * attempt as u64))
867                        .await;
868                }
869            }
870        }
871    }
872    let err = last_err.ok_or_else(|| {
873        anyhow::anyhow!(
874            "failed to fetch transcription provider '{provider_id}' after retries (no error recorded)"
875        )
876    })?;
877    Err(err).context(format!(
878        "failed to fetch transcription provider '{provider_id}' after retries"
879    ))
880}
881
882async fn fetch_manifest_with_retry(
883    fetcher: &impl RegistryFetcherTrait,
884    config: &RegistryConfig,
885) -> Result<RegistryManifest> {
886    let mut last_err = None;
887    let attempts = config.max_retries.saturating_add(1).max(1);
888    for attempt in 1..=attempts {
889        match fetcher.fetch_manifest().await {
890            Ok(m) => return Ok(m),
891            Err(err) => {
892                tracing::debug!(attempt, error = %err, "manifest fetch failed");
893                last_err = Some(err);
894                if attempt < attempts {
895                    tokio::time::sleep(std::time::Duration::from_millis(500 * attempt as u64))
896                        .await;
897                }
898            }
899        }
900    }
901    let err = last_err.ok_or_else(|| {
902        anyhow::anyhow!("failed to fetch registry manifest after retries (no error recorded)")
903    })?;
904    Err(err).context("failed to fetch registry manifest after retries")
905}
906
907async fn fetch_provider_with_retry(
908    fetcher: &impl RegistryFetcherTrait,
909    provider_id: &str,
910    manifest: &RegistryManifest,
911    config: &RegistryConfig,
912) -> Result<RegistryProvider> {
913    let mut last_err = None;
914    let attempts = config.max_retries.saturating_add(1).max(1);
915    for attempt in 1..=attempts {
916        match fetcher.fetch_provider(provider_id, manifest).await {
917            Ok(p) => return Ok(p),
918            Err(err) => {
919                tracing::debug!(attempt, provider = provider_id, error = %err, "provider fetch failed");
920                last_err = Some(err);
921                if attempt < attempts {
922                    tokio::time::sleep(std::time::Duration::from_millis(500 * attempt as u64))
923                        .await;
924                }
925            }
926        }
927    }
928    let err = last_err.ok_or_else(|| {
929        anyhow::anyhow!(
930            "failed to fetch provider '{provider_id}' after retries (no error recorded)"
931        )
932    })?;
933    Err(err).context(format!(
934        "failed to fetch provider '{provider_id}' after retries"
935    ))
936}
937
938pub fn current_timestamp_secs() -> u64 {
939    SystemTime::now()
940        .duration_since(UNIX_EPOCH)
941        .unwrap_or_default()
942        .as_secs()
943}
944
945#[cfg(test)]
946mod tests {
947    use super::super::types::{ManifestProviderEntry, RegistryModel};
948    use super::*;
949
950    fn test_provider(id: &str, name: &str) -> RegistryProvider {
951        RegistryProvider {
952            id: id.to_string(),
953            label: id.to_string(),
954            description: "test".to_string(),
955            kind: "openai-chat-completions".to_string(),
956            api_key_env: "TEST_API_KEY".to_string(),
957            base_url: None,
958            extends: None,
959            tool_calling_mode: None,
960            aggregator: false,
961            defaults: Default::default(),
962            request_options: Default::default(),
963            models: vec![RegistryModel {
964                model_ref: None,
965                api_name: None,
966                name: name.to_string(),
967                task_size: Some("large".to_string()),
968                context_window_tokens: Some(128_000),
969                max_output_tokens: None,
970                recommended_temperature: None,
971                supports_thinking: None,
972                reasoning_levels: Vec::new(),
973                default_reasoning_effort: None,
974                supports_attachments: None,
975                supports_images: None,
976                supports_audio: None,
977                supports_video: None,
978                supports_documents: None,
979                attachments: Default::default(),
980                capabilities: Vec::new(),
981                pricing: None,
982            }],
983        }
984    }
985
986    fn provider_hash(provider: &RegistryProvider) -> String {
987        let json = serde_json::to_string(provider).expect("serialize");
988        hex::encode(Sha256::digest(json.as_bytes()))
989    }
990
991    fn manifest_entry(provider: &RegistryProvider) -> ManifestProviderEntry {
992        ManifestProviderEntry {
993            file: format!("providers/{}.json", provider.id),
994            sha256: provider_hash(provider),
995            model_count: provider.models.len(),
996        }
997    }
998
999    #[test]
1000    fn should_check_after_default_interval() {
1001        let store = RegistryStore::open_memory().expect("open");
1002        let config = RegistryConfig::default();
1003
1004        // No last check recorded → should check.
1005        assert!(should_check_registry_update(&store, &config));
1006
1007        // Just checked now → should not check.
1008        let now = current_timestamp_secs();
1009        save_registry_metadata(
1010            &store,
1011            &RegistryManifest {
1012                version: 1,
1013                updated_at: "2026-01-01T00:00:00Z".to_string(),
1014                providers: std::collections::HashMap::new(),
1015                transcription_providers: Default::default(),
1016                coverage: Default::default(),
1017                models: Default::default(),
1018            },
1019            Some(now),
1020            None,
1021        )
1022        .expect("save");
1023        assert!(!should_check_registry_update(&store, &config));
1024
1025        // 25 hours ago → should check.
1026        let past = now.saturating_sub(25 * 3600);
1027        save_registry_metadata(
1028            &store,
1029            &RegistryManifest {
1030                version: 1,
1031                updated_at: "2026-01-01T00:00:00Z".to_string(),
1032                providers: std::collections::HashMap::new(),
1033                transcription_providers: Default::default(),
1034                coverage: Default::default(),
1035                models: Default::default(),
1036            },
1037            Some(past),
1038            None,
1039        )
1040        .expect("save");
1041        assert!(should_check_registry_update(&store, &config));
1042    }
1043
1044    #[test]
1045    fn should_not_check_when_disabled() {
1046        let store = RegistryStore::open_memory().expect("open");
1047        let mut config = RegistryConfig::default();
1048        config.update_enabled = false;
1049        assert!(!should_check_registry_update(&store, &config));
1050    }
1051
1052    #[test]
1053    fn load_embedded_registry_returns_providers() {
1054        let loaded = load_embedded_registry().expect("embedded registry should load");
1055        assert!(!loaded.providers.is_empty());
1056        assert_eq!(loaded.source, RegistrySource::Embedded);
1057    }
1058
1059    #[test]
1060    fn load_registry_seeds_empty_cache_from_embedded() {
1061        let store = RegistryStore::open_memory().expect("open");
1062        assert!(store.is_empty().unwrap());
1063
1064        let loaded = load_registry(&store);
1065        assert_eq!(loaded.source, RegistrySource::Embedded);
1066        assert!(!loaded.providers.is_empty());
1067
1068        // Cache should now be seeded.
1069        assert!(!store.is_empty().unwrap());
1070        assert!(load_cached_registry(&store).is_some());
1071    }
1072
1073    #[test]
1074    fn load_registry_prefers_cache_when_populated() {
1075        let store = RegistryStore::open_memory().expect("open");
1076        let embedded_manifest = super::embedded_manifest().expect("manifest");
1077        let embedded_providers = super::embedded_providers().expect("providers");
1078        store.replace_all(&embedded_providers).expect("seed");
1079        store
1080            .save_manifest_meta(&embedded_manifest)
1081            .expect("save meta");
1082        save_registry_metadata(&store, &embedded_manifest, None, None).expect("save manifest json");
1083
1084        let loaded = load_registry(&store);
1085        assert_eq!(loaded.source, RegistrySource::Cache);
1086    }
1087
1088    #[test]
1089    fn load_registry_prunes_providers_removed_from_embedded_catalog() {
1090        // Reproduces sticky-cache bug: same manifest version as the embedded
1091        // snapshot, but a deleted provider row still in SQLite (often marked
1092        // local-api-sync after `sync models`).
1093        let store = RegistryStore::open_memory().expect("open");
1094        let embedded_manifest = super::embedded_manifest().expect("manifest");
1095        let embedded_providers = super::embedded_providers().expect("providers");
1096        store.replace_all(&embedded_providers).expect("seed");
1097        store
1098            .save_manifest_meta(&embedded_manifest)
1099            .expect("save meta");
1100        save_registry_metadata(&store, &embedded_manifest, None, None).expect("save manifest json");
1101
1102        let stale = test_provider("stale-removed-provider", "ghost-model");
1103        store
1104            .upsert_provider_with_sha256(&stale, Some(crate::registry::LOCAL_API_SYNC_SHA))
1105            .expect("inject stale provider");
1106        assert!(
1107            store
1108                .load_all_providers()
1109                .expect("load")
1110                .iter()
1111                .any(|p| p.id == "stale-removed-provider"),
1112            "precondition: stale provider must be present before load"
1113        );
1114
1115        let loaded = load_registry(&store);
1116        assert_eq!(loaded.source, RegistrySource::Cache);
1117        assert!(
1118            loaded
1119                .providers
1120                .iter()
1121                .all(|p| p.id != "stale-removed-provider"),
1122            "load_registry must drop providers absent from the embedded catalog"
1123        );
1124        assert!(
1125            store
1126                .load_all_providers()
1127                .expect("reload")
1128                .iter()
1129                .all(|p| p.id != "stale-removed-provider"),
1130            "stale provider must be deleted from the cache"
1131        );
1132    }
1133
1134    #[test]
1135    fn apply_registry_update_atomically_replaces_providers() {
1136        let store = RegistryStore::open_memory().expect("open");
1137        let provider = test_provider("test", "test-model");
1138        let mut manifest = RegistryManifest {
1139            version: 1,
1140            updated_at: "2026-01-01T00:00:00Z".to_string(),
1141            providers: std::collections::HashMap::new(),
1142            transcription_providers: Default::default(),
1143            coverage: Default::default(),
1144            models: Default::default(),
1145        };
1146        manifest
1147            .providers
1148            .insert(provider.id.clone(), manifest_entry(&provider));
1149
1150        apply_registry_update_atomically(&store, &manifest, &[provider]).expect("apply");
1151
1152        let loaded = store.load_all_providers().expect("load");
1153        assert_eq!(loaded.len(), 1);
1154        assert_eq!(loaded[0].id, "test");
1155    }
1156
1157    #[test]
1158    fn validate_registry_hashes_detects_mismatch() {
1159        let provider = test_provider("test", "test-model");
1160        let mut manifest = RegistryManifest {
1161            version: 1,
1162            updated_at: "2026-01-01T00:00:00Z".to_string(),
1163            providers: std::collections::HashMap::new(),
1164            transcription_providers: Default::default(),
1165            coverage: Default::default(),
1166            models: Default::default(),
1167        };
1168        manifest.providers.insert(
1169            provider.id.clone(),
1170            ManifestProviderEntry {
1171                file: "providers/test.json".to_string(),
1172                sha256: "bad-hash".to_string(),
1173                model_count: 1,
1174            },
1175        );
1176
1177        assert!(validate_registry_hashes(&[provider], &manifest).is_err());
1178    }
1179
1180    #[test]
1181    fn validate_registry_hashes_accepts_correct_hash() {
1182        let provider = test_provider("test", "test-model");
1183        let mut manifest = RegistryManifest {
1184            version: 1,
1185            updated_at: "2026-01-01T00:00:00Z".to_string(),
1186            providers: std::collections::HashMap::new(),
1187            transcription_providers: Default::default(),
1188            coverage: Default::default(),
1189            models: Default::default(),
1190        };
1191        manifest
1192            .providers
1193            .insert(provider.id.clone(), manifest_entry(&provider));
1194
1195        assert!(validate_registry_hashes(&[provider], &manifest).is_ok());
1196    }
1197
1198    // ── Async tests using a mock fetcher ───────────────────────────────────
1199
1200    /// Mock fetcher for testing the update flow without network access.
1201    struct MockFetcher {
1202        manifest: Option<RegistryManifest>,
1203        manifest_err: Option<String>,
1204        providers: std::collections::HashMap<String, Result<RegistryProvider, String>>,
1205    }
1206
1207    impl MockFetcher {
1208        fn new() -> Self {
1209            Self {
1210                manifest: None,
1211                manifest_err: Some("not configured".to_string()),
1212                providers: std::collections::HashMap::new(),
1213            }
1214        }
1215
1216        fn with_manifest(mut self, manifest: RegistryManifest) -> Self {
1217            self.manifest = Some(manifest);
1218            self.manifest_err = None;
1219            self
1220        }
1221
1222        fn with_manifest_error(mut self, err: &str) -> Self {
1223            self.manifest = None;
1224            self.manifest_err = Some(err.to_string());
1225            self
1226        }
1227
1228        fn with_provider(mut self, provider: RegistryProvider) -> Self {
1229            self.providers.insert(provider.id.clone(), Ok(provider));
1230            self
1231        }
1232
1233        fn with_provider_error(mut self, id: &str, err: &str) -> Self {
1234            self.providers.insert(id.to_string(), Err(err.to_string()));
1235            self
1236        }
1237    }
1238
1239    impl RegistryFetcherTrait for MockFetcher {
1240        async fn fetch_manifest(&self) -> Result<RegistryManifest> {
1241            self.manifest
1242                .clone()
1243                .ok_or_else(|| anyhow::anyhow!(self.manifest_err.clone().unwrap_or_default()))
1244        }
1245
1246        async fn fetch_provider(
1247            &self,
1248            provider_id: &str,
1249            _manifest: &RegistryManifest,
1250        ) -> Result<RegistryProvider> {
1251            self.providers
1252                .get(provider_id)
1253                .cloned()
1254                .map(|r| r.map_err(|e| anyhow::anyhow!(e)))
1255                .unwrap_or_else(|| {
1256                    Err(anyhow::anyhow!(
1257                        "provider {provider_id} not configured in mock"
1258                    ))
1259                })
1260        }
1261
1262        async fn fetch_transcription_provider(
1263            &self,
1264            provider_id: &str,
1265            _manifest: &RegistryManifest,
1266        ) -> Result<RegistryTranscriptionProvider> {
1267            Err(anyhow::anyhow!(
1268                "transcription provider {provider_id} not configured in mock"
1269            ))
1270        }
1271    }
1272
1273    #[tokio::test]
1274    async fn first_startup_without_cache_seeds_from_embedded() {
1275        let store = RegistryStore::open_memory().expect("open");
1276        assert!(store.is_empty().unwrap());
1277
1278        let loaded = load_registry(&store);
1279        assert!(!loaded.providers.is_empty());
1280        // After load, cache should be seeded.
1281        assert!(!store.is_empty().unwrap());
1282    }
1283
1284    #[tokio::test]
1285    async fn startup_with_valid_cache_uses_cache() {
1286        let store = RegistryStore::open_memory().expect("open");
1287        let provider = test_provider("cached", "cached-model");
1288        let mut manifest = RegistryManifest {
1289            version: 1,
1290            updated_at: "2026-01-01T00:00:00Z".to_string(),
1291            providers: std::collections::HashMap::new(),
1292            transcription_providers: Default::default(),
1293            coverage: Default::default(),
1294            models: Default::default(),
1295        };
1296        manifest
1297            .providers
1298            .insert(provider.id.clone(), manifest_entry(&provider));
1299        store.replace_all(&[provider]).expect("seed");
1300        store.save_manifest_meta(&manifest).expect("save meta");
1301        save_registry_metadata(&store, &manifest, Some(current_timestamp_secs()), None)
1302            .expect("save meta");
1303
1304        let loaded = load_registry(&store);
1305        assert_eq!(loaded.source, RegistrySource::Cache);
1306        assert!(!loaded.providers.is_empty());
1307    }
1308
1309    #[tokio::test]
1310    async fn remote_manifest_equal_to_local_no_update() {
1311        let store = RegistryStore::open_memory().expect("open");
1312        let provider = test_provider("test", "model-1");
1313        let mut manifest = RegistryManifest {
1314            version: 1,
1315            updated_at: "2026-01-01T00:00:00Z".to_string(),
1316            providers: std::collections::HashMap::new(),
1317            transcription_providers: Default::default(),
1318            coverage: Default::default(),
1319            models: Default::default(),
1320        };
1321        manifest
1322            .providers
1323            .insert(provider.id.clone(), manifest_entry(&provider));
1324        store.replace_all(&[provider]).expect("seed");
1325        store.save_manifest_meta(&manifest).expect("save meta");
1326        save_registry_metadata(&store, &manifest, Some(current_timestamp_secs()), None)
1327            .expect("save meta");
1328
1329        let fetcher = MockFetcher::new().with_manifest(manifest);
1330        let config = RegistryConfig::default();
1331        let result = check_registry_manifest(&store, &fetcher, &config).await;
1332        assert!(result.is_ok());
1333        assert!(result.unwrap().is_none());
1334    }
1335
1336    #[tokio::test]
1337    async fn newer_remote_manifest_triggers_update() {
1338        let store = RegistryStore::open_memory().expect("open");
1339        // Seed cache with version 1
1340        let old_provider = test_provider("old", "old-model");
1341        let mut old_manifest = RegistryManifest {
1342            version: 1,
1343            updated_at: "2026-01-01T00:00:00Z".to_string(),
1344            providers: std::collections::HashMap::new(),
1345            transcription_providers: Default::default(),
1346            coverage: Default::default(),
1347            models: Default::default(),
1348        };
1349        old_manifest
1350            .providers
1351            .insert(old_provider.id.clone(), manifest_entry(&old_provider));
1352        store.replace_all(&[old_provider]).expect("seed");
1353        store.save_manifest_meta(&old_manifest).expect("save meta");
1354        save_registry_metadata(&store, &old_manifest, Some(current_timestamp_secs()), None)
1355            .expect("save meta");
1356
1357        // Remote manifest version 2 with a new provider
1358        let new_provider = test_provider("new", "new-model");
1359        let mut new_manifest = RegistryManifest {
1360            version: 2,
1361            updated_at: "2026-07-03T00:00:00Z".to_string(),
1362            providers: std::collections::HashMap::new(),
1363            transcription_providers: Default::default(),
1364            coverage: Default::default(),
1365            models: Default::default(),
1366        };
1367        new_manifest
1368            .providers
1369            .insert(new_provider.id.clone(), manifest_entry(&new_provider));
1370
1371        let fetcher = MockFetcher::new()
1372            .with_manifest(new_manifest.clone())
1373            .with_provider(new_provider);
1374
1375        let config = RegistryConfig::default();
1376        let result = check_registry_manifest(&store, &fetcher, &config).await;
1377        assert!(result.is_ok());
1378        let remote = result.unwrap().unwrap();
1379        assert_eq!(remote.version, 2);
1380    }
1381
1382    #[tokio::test]
1383    async fn network_failure_keeps_existing_registry() {
1384        let store = RegistryStore::open_memory().expect("open");
1385        let provider = test_provider("existing", "model-1");
1386        let mut manifest = RegistryManifest {
1387            version: 1,
1388            updated_at: "2026-01-01T00:00:00Z".to_string(),
1389            providers: std::collections::HashMap::new(),
1390            transcription_providers: Default::default(),
1391            coverage: Default::default(),
1392            models: Default::default(),
1393        };
1394        manifest
1395            .providers
1396            .insert(provider.id.clone(), manifest_entry(&provider));
1397        store.replace_all(&[provider]).expect("seed");
1398        store.save_manifest_meta(&manifest).expect("save meta");
1399        save_registry_metadata(&store, &manifest, Some(current_timestamp_secs()), None)
1400            .expect("save meta");
1401
1402        let fetcher = MockFetcher::new().with_manifest_error("network error");
1403        let config = RegistryConfig::default();
1404        let result = check_registry_manifest(&store, &fetcher, &config).await;
1405        assert!(result.is_err());
1406
1407        // Existing registry should be intact
1408        let loaded = load_registry(&store);
1409        assert_eq!(loaded.source, RegistrySource::Cache);
1410        assert!(!loaded.providers.is_empty());
1411    }
1412
1413    #[tokio::test]
1414    async fn invalid_remote_json_returns_error() {
1415        let store = RegistryStore::open_memory().expect("open");
1416
1417        let fetcher = MockFetcher::new().with_manifest_error("invalid JSON");
1418        let config = RegistryConfig::default();
1419        let result = check_registry_manifest(&store, &fetcher, &config).await;
1420        assert!(result.is_err());
1421    }
1422
1423    #[tokio::test]
1424    async fn invalid_hash_rejected() {
1425        let provider = test_provider("bad-hash", "model-1");
1426        let mut manifest = RegistryManifest {
1427            version: 1,
1428            updated_at: "2026-01-01T00:00:00Z".to_string(),
1429            providers: std::collections::HashMap::new(),
1430            transcription_providers: Default::default(),
1431            coverage: Default::default(),
1432            models: Default::default(),
1433        };
1434        manifest.providers.insert(
1435            provider.id.clone(),
1436            ManifestProviderEntry {
1437                file: "providers/bad-hash.json".to_string(),
1438                sha256: "0000000000000000000000000000000000000000000000000000000000000000"
1439                    .to_string(),
1440                model_count: 1,
1441            },
1442        );
1443
1444        assert!(validate_registry_hashes(&[provider], &manifest).is_err());
1445    }
1446
1447    #[tokio::test]
1448    async fn partial_update_failure_keeps_previous() {
1449        let store = RegistryStore::open_memory().expect("open");
1450        let old_provider = test_provider("old", "old-model");
1451        let mut old_manifest = RegistryManifest {
1452            version: 1,
1453            updated_at: "2026-01-01T00:00:00Z".to_string(),
1454            providers: std::collections::HashMap::new(),
1455            transcription_providers: Default::default(),
1456            coverage: Default::default(),
1457            models: Default::default(),
1458        };
1459        old_manifest
1460            .providers
1461            .insert(old_provider.id.clone(), manifest_entry(&old_provider));
1462        store.replace_all(&[old_provider]).expect("seed");
1463        store.save_manifest_meta(&old_manifest).expect("save meta");
1464        save_registry_metadata(&store, &old_manifest, Some(current_timestamp_secs()), None)
1465            .expect("save meta");
1466
1467        // New manifest with two providers; one will fail to fetch
1468        let good_provider = test_provider("good", "good-model");
1469        let bad_provider = test_provider("bad", "bad-model");
1470        let mut new_manifest = RegistryManifest {
1471            version: 2,
1472            updated_at: "2026-07-03T00:00:00Z".to_string(),
1473            providers: std::collections::HashMap::new(),
1474            transcription_providers: Default::default(),
1475            coverage: Default::default(),
1476            models: Default::default(),
1477        };
1478        new_manifest
1479            .providers
1480            .insert(good_provider.id.clone(), manifest_entry(&good_provider));
1481        new_manifest
1482            .providers
1483            .insert(bad_provider.id.clone(), manifest_entry(&bad_provider));
1484
1485        let fetcher = MockFetcher::new()
1486            .with_manifest(new_manifest)
1487            .with_provider(good_provider)
1488            .with_provider_error("bad", "network error for bad provider");
1489
1490        let config = RegistryConfig::default();
1491        let updated = run_registry_update_check(&store, &fetcher, &config).await;
1492        assert!(!updated, "update should have failed");
1493
1494        // Previous registry should still be intact
1495        let loaded = store.load_all_providers().expect("load");
1496        assert_eq!(loaded.len(), 1);
1497        assert_eq!(loaded[0].id, "old");
1498    }
1499
1500    #[tokio::test]
1501    async fn rollback_to_previous_registry_on_apply_failure() {
1502        let store = RegistryStore::open_memory().expect("open");
1503        let old_provider = test_provider("old", "old-model");
1504        let mut old_manifest = RegistryManifest {
1505            version: 1,
1506            updated_at: "2026-01-01T00:00:00Z".to_string(),
1507            providers: std::collections::HashMap::new(),
1508            transcription_providers: Default::default(),
1509            coverage: Default::default(),
1510            models: Default::default(),
1511        };
1512        old_manifest
1513            .providers
1514            .insert(old_provider.id.clone(), manifest_entry(&old_provider));
1515        store.replace_all(&[old_provider]).expect("seed");
1516        store.save_manifest_meta(&old_manifest).expect("save meta");
1517
1518        // Validate hashes first — this should fail and prevent the apply.
1519        let new_provider = test_provider("new", "new-model");
1520        let mut new_manifest = RegistryManifest {
1521            version: 2,
1522            updated_at: "2026-07-03T00:00:00Z".to_string(),
1523            providers: std::collections::HashMap::new(),
1524            transcription_providers: Default::default(),
1525            coverage: Default::default(),
1526            models: Default::default(),
1527        };
1528        new_manifest.providers.insert(
1529            new_provider.id.clone(),
1530            ManifestProviderEntry {
1531                file: "providers/new.json".to_string(),
1532                sha256: "wrong-hash".to_string(),
1533                model_count: 1,
1534            },
1535        );
1536
1537        // Hash validation should catch the mismatch before apply.
1538        assert!(validate_registry_hashes(&[new_provider], &new_manifest).is_err());
1539
1540        // Since validation failed, apply is never called. Old provider should still be there.
1541        let loaded = store.load_all_providers().expect("load");
1542        assert_eq!(loaded.len(), 1);
1543        assert_eq!(loaded[0].id, "old");
1544    }
1545
1546    #[test]
1547    fn respecting_interval_with_jitter() {
1548        let store = RegistryStore::open_memory().expect("open");
1549        let config = RegistryConfig::default();
1550        let now = current_timestamp_secs();
1551
1552        // Just checked → should not check
1553        save_registry_metadata(
1554            &store,
1555            &RegistryManifest {
1556                version: 1,
1557                updated_at: "2026-01-01T00:00:00Z".to_string(),
1558                providers: std::collections::HashMap::new(),
1559                transcription_providers: Default::default(),
1560                coverage: Default::default(),
1561                models: Default::default(),
1562            },
1563            Some(now),
1564            None,
1565        )
1566        .expect("save");
1567        assert!(!should_check_registry_update(&store, &config));
1568
1569        // 30h ago → should check (24h + up to 6h jitter = max 30h, 30h is past worst case)
1570        let past = now.saturating_sub(31 * 3600);
1571        save_registry_metadata(
1572            &store,
1573            &RegistryManifest {
1574                version: 1,
1575                updated_at: "2026-01-01T00:00:00Z".to_string(),
1576                providers: std::collections::HashMap::new(),
1577                transcription_providers: Default::default(),
1578                coverage: Default::default(),
1579                models: Default::default(),
1580            },
1581            Some(past),
1582            None,
1583        )
1584        .expect("save");
1585        assert!(should_check_registry_update(&store, &config));
1586
1587        // Verify jitter interval is within expected bounds
1588        let interval = registry_check_interval_with_jitter(&config);
1589        let base = config.check_interval_hours * 3600;
1590        let max = base + config.check_jitter_hours * 3600;
1591        assert!(interval >= base && interval <= max);
1592    }
1593}