use anyhow::{Context, Result};
use std::path::Path;
use super::types::{RegistryManifest, RegistryProvider};
const REGISTRY_BASE_URL: &str = "https://raw.githubusercontent.com/enrell/navi/main/registry";
const FETCH_TIMEOUT_SECS: u64 = 15;
pub struct RegistryFetcher {
client: reqwest::Client,
}
impl RegistryFetcher {
pub fn new() -> Self {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
.user_agent("navi-registry-fetcher/1.0")
.build()
.expect("failed to build HTTP client");
Self { client }
}
pub async fn fetch_manifest(&self) -> Result<RegistryManifest> {
let url = format!("{REGISTRY_BASE_URL}/manifest.json");
let resp = self
.client
.get(&url)
.send()
.await
.with_context(|| format!("failed to fetch manifest from {url}"))?;
resp.error_for_status()
.with_context(|| format!("manifest request failed: {url}"))?
.json::<RegistryManifest>()
.await
.context("failed to parse manifest JSON")
}
pub async fn fetch_provider(
&self,
provider_id: &str,
manifest: &RegistryManifest,
) -> Result<RegistryProvider> {
let entry = manifest
.providers
.get(provider_id)
.with_context(|| format!("provider '{provider_id}' not in manifest"))?;
let url = format!("{REGISTRY_BASE_URL}/{}", entry.file);
let resp = self
.client
.get(&url)
.send()
.await
.with_context(|| format!("failed to fetch provider '{provider_id}' from {url}"))?;
let text = resp
.error_for_status()
.with_context(|| format!("provider '{provider_id}' request failed: {url}"))?
.text()
.await
.context("failed to read provider response body")?;
let _ = &entry.sha256;
serde_json::from_str::<RegistryProvider>(&text)
.with_context(|| format!("failed to parse provider '{provider_id}' JSON"))
}
pub async fn fetch_all_providers(
&self,
manifest: &RegistryManifest,
) -> Result<Vec<RegistryProvider>> {
let mut providers = Vec::new();
for provider_id in manifest.providers.keys() {
let provider = self.fetch_provider(provider_id, manifest).await?;
providers.push(provider);
}
Ok(providers)
}
pub fn base_url(&self) -> &str {
REGISTRY_BASE_URL
}
}
impl Default for RegistryFetcher {
fn default() -> Self {
Self::new()
}
}
pub fn load_local_registry(
registry_dir: &Path,
) -> Result<(RegistryManifest, Vec<RegistryProvider>)> {
let manifest_path = registry_dir.join("manifest.json");
let manifest_text = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("failed to read {}", manifest_path.display()))?;
let manifest = serde_json::from_str::<RegistryManifest>(&manifest_text)
.with_context(|| format!("failed to parse {}", manifest_path.display()))?;
let mut providers = Vec::new();
for (provider_id, entry) in &manifest.providers {
let provider_path = registry_dir.join(&entry.file);
let provider_text = std::fs::read_to_string(&provider_path)
.with_context(|| format!("failed to read {}", provider_path.display()))?;
let provider = serde_json::from_str::<RegistryProvider>(&provider_text)
.with_context(|| format!("failed to parse provider '{provider_id}' JSON"))?;
providers.push(provider);
}
Ok((manifest, providers))
}
pub fn sync_local_registry(
store: &super::store::RegistryStore,
registry_dir: &Path,
) -> Result<bool> {
let (manifest, providers) = load_local_registry(registry_dir)?;
store.replace_all(&providers)?;
store.save_manifest_meta(&manifest)?;
tracing::info!(
version = manifest.version,
providers = providers.len(),
models = providers.iter().map(|p| p.models.len()).sum::<usize>(),
path = %registry_dir.display(),
"registry cache updated from local directory"
);
Ok(true)
}
pub async fn sync_registry(
store: &super::store::RegistryStore,
fetcher: &RegistryFetcher,
force: bool,
) -> Result<bool> {
if !force {
if let Some(updated_at) = store.manifest_updated_at()? {
if let Ok(parsed) = parse_iso_timestamp(&updated_at) {
if parsed.hours_ago < 24 && !store.is_empty()? {
tracing::debug!("registry cache is fresh, skipping fetch");
return Ok(false);
}
}
}
}
tracing::info!("fetching remote registry manifest");
let manifest = fetcher.fetch_manifest().await?;
if !force {
if let Some(stored_version) = store.manifest_version()? {
if stored_version >= manifest.version && !store.is_empty()? {
tracing::debug!(
stored = stored_version,
remote = manifest.version,
"registry manifest is up-to-date"
);
return Ok(false);
}
}
}
tracing::info!(
version = manifest.version,
providers = manifest.providers.len(),
"fetching all provider definitions"
);
let providers = fetcher.fetch_all_providers(&manifest).await?;
store.replace_all(&providers)?;
store.save_manifest_meta(&manifest)?;
tracing::info!(
providers = providers.len(),
models = providers.iter().map(|p| p.models.len()).sum::<usize>(),
"registry cache updated"
);
Ok(true)
}
fn parse_iso_timestamp(iso: &str) -> Result<TimestampDiff> {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let date_part = iso.get(..10).context("invalid date format")?;
let time_part = iso.get(11..19).context("invalid time format")?;
let parts: Vec<u64> = date_part
.split('-')
.filter_map(|s| s.parse().ok())
.collect();
let time_parts: Vec<u64> = time_part
.split(':')
.filter_map(|s| s.parse().ok())
.collect();
if parts.len() < 3 || time_parts.len() < 3 {
anyhow::bail!("invalid timestamp format: {iso}");
}
let year = parts[0];
let month = parts[1];
let day = parts[2];
let hours = time_parts[0];
let minutes = time_parts[1];
let seconds = time_parts[2];
let days_since_epoch = (year - 1970) * 365 + (month - 1) * 30 + (day - 1);
let epoch = days_since_epoch * 86400 + hours * 3600 + minutes * 60 + seconds;
let diff_secs = now_secs.saturating_sub(epoch);
Ok(TimestampDiff {
hours_ago: diff_secs / 3600,
})
}
struct TimestampDiff {
hours_ago: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_iso_timestamp_recent() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let hours = (now % 86400) / 3600;
let days = now / 86400;
let year = 1970 + days / 365;
let month = 1 + (days % 365) / 30;
let day = 1 + (days % 365) % 30;
let iso = format!("{:04}-{:02}-{:02}T{:02}:00:00Z", year, month, day, hours);
let diff = parse_iso_timestamp(&iso).expect("parse");
assert!(
diff.hours_ago <= 2,
"expected <= 2h, got {}",
diff.hours_ago
);
}
#[test]
fn parse_iso_timestamp_old() {
let diff = parse_iso_timestamp("2020-01-01T00:00:00Z").expect("parse");
assert!(diff.hours_ago > 1000);
}
#[test]
fn load_local_registry_reads_manifest_and_providers() {
let tempdir = tempfile::tempdir().expect("tempdir");
let registry_dir = tempdir.path();
std::fs::create_dir(registry_dir.join("providers")).expect("providers dir");
std::fs::write(
registry_dir.join("manifest.json"),
r#"{
"version": 1,
"updated_at": "2026-01-01T00:00:00Z",
"providers": {
"local": {
"file": "providers/local.json",
"sha256": "",
"model_count": 1
}
}
}"#,
)
.expect("manifest");
std::fs::write(
registry_dir.join("providers/local.json"),
r#"{
"id": "local",
"label": "Local",
"kind": "openai-chat-completions",
"api_key_env": "LOCAL_API_KEY",
"base_url": null,
"models": [
{
"name": "local-model",
"task_size": "large",
"context_window_tokens": 123456
}
]
}"#,
)
.expect("provider");
let (manifest, providers) = load_local_registry(registry_dir).expect("load");
assert_eq!(manifest.version, 1);
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].id, "local");
assert_eq!(providers[0].models[0].context_window_tokens, Some(123_456));
}
}