Skip to main content

batuta/stack/crates_io/
client.rs

1//! Crates.io API client implementation.
2
3use super::cache::PersistentCache;
4use super::types::{CacheEntry, CrateResponse, DependencyData, DependencyResponse};
5use anyhow::{anyhow, Result};
6use serde::de::DeserializeOwned;
7use std::collections::HashMap;
8use std::time::Duration;
9
10/// Client for interacting with crates.io API
11#[derive(Debug)]
12pub struct CratesIoClient {
13    /// HTTP client
14    #[cfg(feature = "native")]
15    client: reqwest::Client,
16
17    /// In-memory cache for crate info (15 minute TTL)
18    pub cache: HashMap<String, CacheEntry<CrateResponse>>,
19
20    /// Persistent cache for offline mode
21    pub persistent_cache: Option<PersistentCache>,
22
23    /// Cache TTL
24    pub cache_ttl: Duration,
25
26    /// Offline mode - only use cached data
27    offline: bool,
28}
29
30impl CratesIoClient {
31    /// Create a new crates.io client
32    #[cfg(feature = "native")]
33    pub fn new() -> Self {
34        let client = reqwest::Client::builder()
35            .user_agent("batuta/0.1 (https://github.com/paiml/batuta)")
36            .timeout(Duration::from_secs(30))
37            .build()
38            .expect("Failed to create HTTP client");
39
40        Self {
41            client,
42            cache: HashMap::new(),
43            persistent_cache: None,
44            cache_ttl: Duration::from_secs(15 * 60), // 15 minutes
45            offline: false,
46        }
47    }
48
49    /// Create a client with custom TTL
50    #[cfg(feature = "native")]
51    pub fn with_cache_ttl(mut self, ttl: Duration) -> Self {
52        self.cache_ttl = ttl;
53        self
54    }
55
56    /// Enable persistent file-based cache
57    #[cfg(feature = "native")]
58    pub fn with_persistent_cache(mut self) -> Self {
59        self.persistent_cache = Some(PersistentCache::load());
60        self
61    }
62
63    /// Set offline mode (only use cached data)
64    #[cfg(feature = "native")]
65    pub fn set_offline(&mut self, offline: bool) {
66        self.offline = offline;
67    }
68
69    /// Check if client is in offline mode
70    #[cfg(feature = "native")]
71    pub fn is_offline(&self) -> bool {
72        self.offline
73    }
74
75    /// HTTP GET + status check + JSON parse helper.
76    ///
77    /// Performs a GET request to `url`, checks for 404 / non-success status,
78    /// and deserialises the response body into `T`.
79    #[cfg(feature = "native")]
80    async fn fetch_and_parse<T: DeserializeOwned>(&self, url: &str, context: &str) -> Result<T> {
81        let response = self
82            .client
83            .get(url)
84            .send()
85            .await
86            .map_err(|e| anyhow!("Failed to fetch {}: {}", context, e))?;
87
88        if response.status() == reqwest::StatusCode::NOT_FOUND {
89            return Err(anyhow!("'{}' not found on crates.io", context));
90        }
91
92        if !response.status().is_success() {
93            return Err(anyhow!("Failed to fetch {}: HTTP {}", context, response.status()));
94        }
95
96        response.json().await.map_err(|e| anyhow!("Failed to parse {} response: {}", context, e))
97    }
98
99    /// Get crate info from crates.io (cached)
100    #[cfg(feature = "native")]
101    pub async fn get_crate(&mut self, name: &str) -> Result<CrateResponse> {
102        // Check in-memory cache first
103        if let Some(entry) = self.cache.get(name) {
104            if !entry.is_expired() {
105                return Ok(entry.value.clone());
106            }
107        }
108
109        // Check persistent cache
110        if let Some(ref persistent) = self.persistent_cache {
111            if let Some(response) = persistent.get(name) {
112                // Also add to in-memory cache for faster subsequent access
113                self.cache
114                    .insert(name.to_string(), CacheEntry::new(response.clone(), self.cache_ttl));
115                return Ok(response.clone());
116            }
117        }
118
119        // In offline mode, return error if not in cache
120        if self.offline {
121            return Err(anyhow!("Crate '{}' not found in cache (offline mode)", name));
122        }
123
124        // Fetch from API
125        let url = format!("https://crates.io/api/v1/crates/{}", name);
126        let crate_response: CrateResponse =
127            self.fetch_and_parse(&url, &format!("crate {}", name)).await?;
128
129        // Cache the result in memory
130        self.cache
131            .insert(name.to_string(), CacheEntry::new(crate_response.clone(), self.cache_ttl));
132
133        // Also save to persistent cache
134        if let Some(ref mut persistent) = self.persistent_cache {
135            persistent.insert(name.to_string(), crate_response.clone(), self.cache_ttl);
136            let _ = persistent.save(); // Ignore save errors
137        }
138
139        Ok(crate_response)
140    }
141
142    /// Get the latest version of a crate
143    #[cfg(feature = "native")]
144    pub async fn get_latest_version(&mut self, name: &str) -> Result<semver::Version> {
145        let response = self.get_crate(name).await?;
146        response.krate.max_version.parse().map_err(|e| anyhow!("Failed to parse version: {}", e))
147    }
148
149    /// Check if a specific version is published
150    #[cfg(feature = "native")]
151    pub async fn is_version_published(
152        &mut self,
153        name: &str,
154        version: &semver::Version,
155    ) -> Result<bool> {
156        let response = self.get_crate(name).await?;
157        let version_str = version.to_string();
158
159        Ok(response.versions.iter().any(|v| v.num == version_str && !v.yanked))
160    }
161
162    /// Check if a crate exists on crates.io
163    #[cfg(feature = "native")]
164    pub async fn crate_exists(&mut self, name: &str) -> bool {
165        self.get_crate(name).await.is_ok()
166    }
167
168    /// Get all published versions of a crate (non-yanked)
169    #[cfg(feature = "native")]
170    pub async fn get_versions(&mut self, name: &str) -> Result<Vec<semver::Version>> {
171        let response = self.get_crate(name).await?;
172
173        let mut versions: Vec<semver::Version> = response
174            .versions
175            .iter()
176            .filter(|v| !v.yanked)
177            .filter_map(|v| v.num.parse().ok())
178            .collect();
179
180        versions.sort();
181        versions.reverse(); // Newest first
182
183        Ok(versions)
184    }
185
186    /// Get dependencies for a specific crate version from crates.io
187    ///
188    /// Fetches from `/api/v1/crates/{name}/{version}/dependencies`
189    #[cfg(feature = "native")]
190    pub async fn get_dependencies(
191        &mut self,
192        name: &str,
193        version: &str,
194    ) -> Result<Vec<DependencyData>> {
195        // In offline mode, return error
196        if self.offline {
197            return Err(anyhow!(
198                "Cannot fetch dependencies for {}@{} (offline mode)",
199                name,
200                version
201            ));
202        }
203
204        let url = format!("https://crates.io/api/v1/crates/{}/{}/dependencies", name, version);
205        let context = format!("dependencies for {}@{}", name, version);
206
207        let dep_response: DependencyResponse = self.fetch_and_parse(&url, &context).await?;
208
209        Ok(dep_response.dependencies)
210    }
211
212    /// Verify a crate is available on crates.io (post-publish check)
213    #[cfg(feature = "native")]
214    pub async fn verify_available(&mut self, name: &str, version: &semver::Version) -> Result<()> {
215        // Clear cache to get fresh data
216        self.cache.remove(name);
217
218        let max_attempts = 10;
219        let delay = Duration::from_secs(3);
220
221        for attempt in 1..=max_attempts {
222            if self.is_version_published(name, version).await? {
223                return Ok(());
224            }
225
226            if attempt < max_attempts {
227                tokio::time::sleep(delay).await;
228            }
229        }
230
231        Err(anyhow!(
232            "Crate {}@{} not available on crates.io after {} attempts",
233            name,
234            version,
235            max_attempts
236        ))
237    }
238
239    /// Clear the cache
240    pub fn clear_cache(&mut self) {
241        self.cache.clear();
242    }
243
244    /// Clear expired cache entries
245    pub fn clear_expired(&mut self) {
246        self.cache.retain(|_, entry| !entry.is_expired());
247    }
248}
249
250#[cfg(feature = "native")]
251impl Default for CratesIoClient {
252    fn default() -> Self {
253        Self::new()
254    }
255}