batuta/stack/crates_io/
client.rs1use 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#[derive(Debug)]
12pub struct CratesIoClient {
13 #[cfg(feature = "native")]
15 client: reqwest::Client,
16
17 pub cache: HashMap<String, CacheEntry<CrateResponse>>,
19
20 pub persistent_cache: Option<PersistentCache>,
22
23 pub cache_ttl: Duration,
25
26 offline: bool,
28}
29
30impl CratesIoClient {
31 #[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), offline: false,
46 }
47 }
48
49 #[cfg(feature = "native")]
51 pub fn with_cache_ttl(mut self, ttl: Duration) -> Self {
52 self.cache_ttl = ttl;
53 self
54 }
55
56 #[cfg(feature = "native")]
58 pub fn with_persistent_cache(mut self) -> Self {
59 self.persistent_cache = Some(PersistentCache::load());
60 self
61 }
62
63 #[cfg(feature = "native")]
65 pub fn set_offline(&mut self, offline: bool) {
66 self.offline = offline;
67 }
68
69 #[cfg(feature = "native")]
71 pub fn is_offline(&self) -> bool {
72 self.offline
73 }
74
75 #[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 #[cfg(feature = "native")]
101 pub async fn get_crate(&mut self, name: &str) -> Result<CrateResponse> {
102 if let Some(entry) = self.cache.get(name) {
104 if !entry.is_expired() {
105 return Ok(entry.value.clone());
106 }
107 }
108
109 if let Some(ref persistent) = self.persistent_cache {
111 if let Some(response) = persistent.get(name) {
112 self.cache
114 .insert(name.to_string(), CacheEntry::new(response.clone(), self.cache_ttl));
115 return Ok(response.clone());
116 }
117 }
118
119 if self.offline {
121 return Err(anyhow!("Crate '{}' not found in cache (offline mode)", name));
122 }
123
124 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 self.cache
131 .insert(name.to_string(), CacheEntry::new(crate_response.clone(), self.cache_ttl));
132
133 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(); }
138
139 Ok(crate_response)
140 }
141
142 #[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 #[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 #[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 #[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(); Ok(versions)
184 }
185
186 #[cfg(feature = "native")]
190 pub async fn get_dependencies(
191 &mut self,
192 name: &str,
193 version: &str,
194 ) -> Result<Vec<DependencyData>> {
195 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 #[cfg(feature = "native")]
214 pub async fn verify_available(&mut self, name: &str, version: &semver::Version) -> Result<()> {
215 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 pub fn clear_cache(&mut self) {
241 self.cache.clear();
242 }
243
244 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}