use super::cache::PersistentCache;
use super::types::{CacheEntry, CrateResponse, DependencyData, DependencyResponse};
use anyhow::{anyhow, Result};
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug)]
pub struct CratesIoClient {
#[cfg(feature = "native")]
client: reqwest::Client,
pub cache: HashMap<String, CacheEntry<CrateResponse>>,
pub persistent_cache: Option<PersistentCache>,
pub cache_ttl: Duration,
offline: bool,
}
impl CratesIoClient {
#[cfg(feature = "native")]
pub fn new() -> Self {
let client = reqwest::Client::builder()
.user_agent("batuta/0.1 (https://github.com/paiml/batuta)")
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client");
Self {
client,
cache: HashMap::new(),
persistent_cache: None,
cache_ttl: Duration::from_secs(15 * 60), offline: false,
}
}
#[cfg(feature = "native")]
pub fn with_cache_ttl(mut self, ttl: Duration) -> Self {
self.cache_ttl = ttl;
self
}
#[cfg(feature = "native")]
pub fn with_persistent_cache(mut self) -> Self {
self.persistent_cache = Some(PersistentCache::load());
self
}
#[cfg(feature = "native")]
pub fn set_offline(&mut self, offline: bool) {
self.offline = offline;
}
#[cfg(feature = "native")]
pub fn is_offline(&self) -> bool {
self.offline
}
#[cfg(feature = "native")]
async fn fetch_and_parse<T: DeserializeOwned>(&self, url: &str, context: &str) -> Result<T> {
let response = self
.client
.get(url)
.send()
.await
.map_err(|e| anyhow!("Failed to fetch {}: {}", context, e))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(anyhow!("'{}' not found on crates.io", context));
}
if !response.status().is_success() {
return Err(anyhow!("Failed to fetch {}: HTTP {}", context, response.status()));
}
response.json().await.map_err(|e| anyhow!("Failed to parse {} response: {}", context, e))
}
#[cfg(feature = "native")]
pub async fn get_crate(&mut self, name: &str) -> Result<CrateResponse> {
if let Some(entry) = self.cache.get(name) {
if !entry.is_expired() {
return Ok(entry.value.clone());
}
}
if let Some(ref persistent) = self.persistent_cache {
if let Some(response) = persistent.get(name) {
self.cache
.insert(name.to_string(), CacheEntry::new(response.clone(), self.cache_ttl));
return Ok(response.clone());
}
}
if self.offline {
return Err(anyhow!("Crate '{}' not found in cache (offline mode)", name));
}
let url = format!("https://crates.io/api/v1/crates/{}", name);
let crate_response: CrateResponse =
self.fetch_and_parse(&url, &format!("crate {}", name)).await?;
self.cache
.insert(name.to_string(), CacheEntry::new(crate_response.clone(), self.cache_ttl));
if let Some(ref mut persistent) = self.persistent_cache {
persistent.insert(name.to_string(), crate_response.clone(), self.cache_ttl);
let _ = persistent.save(); }
Ok(crate_response)
}
#[cfg(feature = "native")]
pub async fn get_latest_version(&mut self, name: &str) -> Result<semver::Version> {
let response = self.get_crate(name).await?;
response.krate.max_version.parse().map_err(|e| anyhow!("Failed to parse version: {}", e))
}
#[cfg(feature = "native")]
pub async fn is_version_published(
&mut self,
name: &str,
version: &semver::Version,
) -> Result<bool> {
let response = self.get_crate(name).await?;
let version_str = version.to_string();
Ok(response.versions.iter().any(|v| v.num == version_str && !v.yanked))
}
#[cfg(feature = "native")]
pub async fn crate_exists(&mut self, name: &str) -> bool {
self.get_crate(name).await.is_ok()
}
#[cfg(feature = "native")]
pub async fn get_versions(&mut self, name: &str) -> Result<Vec<semver::Version>> {
let response = self.get_crate(name).await?;
let mut versions: Vec<semver::Version> = response
.versions
.iter()
.filter(|v| !v.yanked)
.filter_map(|v| v.num.parse().ok())
.collect();
versions.sort();
versions.reverse();
Ok(versions)
}
#[cfg(feature = "native")]
pub async fn get_dependencies(
&mut self,
name: &str,
version: &str,
) -> Result<Vec<DependencyData>> {
if self.offline {
return Err(anyhow!(
"Cannot fetch dependencies for {}@{} (offline mode)",
name,
version
));
}
let url = format!("https://crates.io/api/v1/crates/{}/{}/dependencies", name, version);
let context = format!("dependencies for {}@{}", name, version);
let dep_response: DependencyResponse = self.fetch_and_parse(&url, &context).await?;
Ok(dep_response.dependencies)
}
#[cfg(feature = "native")]
pub async fn verify_available(&mut self, name: &str, version: &semver::Version) -> Result<()> {
self.cache.remove(name);
let max_attempts = 10;
let delay = Duration::from_secs(3);
for attempt in 1..=max_attempts {
if self.is_version_published(name, version).await? {
return Ok(());
}
if attempt < max_attempts {
tokio::time::sleep(delay).await;
}
}
Err(anyhow!(
"Crate {}@{} not available on crates.io after {} attempts",
name,
version,
max_attempts
))
}
pub fn clear_cache(&mut self) {
self.cache.clear();
}
pub fn clear_expired(&mut self) {
self.cache.retain(|_, entry| !entry.is_expired());
}
}
#[cfg(feature = "native")]
impl Default for CratesIoClient {
fn default() -> Self {
Self::new()
}
}