1use std::collections::HashMap;
8use std::fs;
9use std::path::PathBuf;
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12use anyhow::{Context, Result};
13use directories::ProjectDirs;
14use serde::{Deserialize, Serialize};
15
16const USER_AGENT: &str = concat!(
17 "cargo-bless/",
18 env!("CARGO_PKG_VERSION"),
19 " (https://github.com/Ruffian-L/cargo-bless)"
20);
21const CACHE_TTL_SECS: u64 = 3600; #[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CrateIntel {
28 pub name: String,
29 pub latest_version: String,
30 pub downloads: u64,
31 pub recent_downloads: Option<u64>,
32 pub last_updated: String,
33 pub repository_url: Option<String>,
34 pub description: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct GitHubActivity {
40 pub last_push: String,
41 pub stars: u64,
42 pub is_archived: bool,
43 pub open_issues: u64,
44}
45
46#[derive(Debug, Serialize, Deserialize)]
48struct CacheEntry<T> {
49 data: T,
50 fetched_at: u64,
51}
52
53impl<T> CacheEntry<T> {
54 fn is_fresh(&self) -> bool {
55 let now = SystemTime::now()
56 .duration_since(UNIX_EPOCH)
57 .unwrap_or_default()
58 .as_secs();
59 now.saturating_sub(self.fetched_at) < CACHE_TTL_SECS
60 }
61
62 fn new(data: T) -> Self {
63 let fetched_at = SystemTime::now()
64 .duration_since(UNIX_EPOCH)
65 .unwrap_or_default()
66 .as_secs();
67 Self { data, fetched_at }
68 }
69}
70
71pub struct IntelClient {
75 client: crates_io_api::SyncClient,
76 http: reqwest::blocking::Client,
77 cache_dir: Option<PathBuf>,
78}
79
80impl IntelClient {
81 pub fn new() -> Result<Self> {
83 let client = crates_io_api::SyncClient::new(USER_AGENT, Duration::from_secs(1))
84 .context("failed to create crates.io client")?;
85 let http = reqwest::blocking::Client::builder()
86 .user_agent(USER_AGENT)
87 .timeout(Duration::from_secs(10))
88 .build()
89 .context("failed to create GitHub HTTP client")?;
90
91 let mut cache_dir = ProjectDirs::from("rs", "", "cargo-bless")
92 .map(|dirs| dirs.cache_dir().to_path_buf())
93 .filter(|path| !path.as_os_str().is_empty());
94
95 if let Some(dir) = &cache_dir {
96 if let Err(err) = fs::create_dir_all(dir) {
97 eprintln!(
98 "⚠️ Could not create cache directory at {}: {}. Continuing without cache.",
99 dir.display(),
100 err
101 );
102 cache_dir = None;
103 }
104 }
105
106 Ok(Self {
107 client,
108 http,
109 cache_dir,
110 })
111 }
112
113 pub fn fetch_crate_intel(&self, name: &str) -> Result<CrateIntel> {
115 let cache_path = self
117 .cache_dir
118 .as_ref()
119 .map(|dir| dir.join(format!("{}.json", name)));
120
121 if let Some(path) = &cache_path {
122 if let Ok(contents) = fs::read_to_string(path) {
123 if let Ok(entry) = serde_json::from_str::<CacheEntry<CrateIntel>>(&contents) {
124 if entry.is_fresh() {
125 return Ok(entry.data);
126 }
127 }
128 }
129 }
130
131 let response = self
133 .client
134 .get_crate(name)
135 .with_context(|| format!("failed to fetch crate info for '{}'", name))?;
136
137 let crate_data = &response.crate_data;
138 let latest_version = response
139 .versions
140 .first()
141 .map(|v| v.num.clone())
142 .unwrap_or_else(|| crate_data.max_version.clone());
143
144 let intel = CrateIntel {
145 name: name.to_string(),
146 latest_version,
147 downloads: crate_data.downloads,
148 recent_downloads: crate_data.recent_downloads,
149 last_updated: crate_data.updated_at.to_string(),
150 repository_url: crate_data.repository.clone(),
151 description: crate_data.description.clone(),
152 };
153
154 if let Some(path) = &cache_path {
156 let entry = CacheEntry::new(intel.clone());
157 if let Ok(json) = serde_json::to_string_pretty(&entry) {
158 let _ = fs::write(path, json);
159 }
160 }
161
162 Ok(intel)
163 }
164
165 pub fn fetch_github_activity(&self, repo_url: &str) -> Option<GitHubActivity> {
168 let (owner, repo) = parse_github_url(repo_url)?;
169
170 let url = format!("https://api.github.com/repos/{owner}/{repo}");
171 let repo_info = self
172 .http
173 .get(url)
174 .send()
175 .ok()?
176 .error_for_status()
177 .ok()?
178 .json::<GitHubRepoResponse>()
179 .ok()?;
180
181 Some(GitHubActivity {
182 last_push: repo_info.pushed_at.unwrap_or_else(|| "unknown".into()),
183 stars: repo_info.stargazers_count.unwrap_or(0),
184 is_archived: repo_info.archived.unwrap_or(false),
185 open_issues: repo_info.open_issues_count.unwrap_or(0),
186 })
187 }
188
189 pub fn fetch_bulk_intel(&self, crate_names: &[&str]) -> HashMap<String, CrateIntel> {
192 let mut intel = HashMap::new();
193 for name in crate_names {
194 match self.fetch_crate_intel(name) {
195 Ok(info) => {
196 intel.insert(name.to_string(), info);
197 }
198 Err(_) => {
199 }
201 }
202 }
203 intel
204 }
205}
206
207#[derive(Debug, Deserialize)]
208struct GitHubRepoResponse {
209 pushed_at: Option<String>,
210 stargazers_count: Option<u64>,
211 archived: Option<bool>,
212 open_issues_count: Option<u64>,
213}
214
215pub fn parse_github_url(url: &str) -> Option<(String, String)> {
221 let url = url.trim().trim_end_matches('/');
222
223 let after_github = if let Some(pos) = url.find("github.com/") {
225 &url[pos + "github.com/".len()..]
226 } else {
227 return None;
228 };
229
230 let parts: Vec<&str> = after_github.splitn(3, '/').collect();
231 if parts.len() < 2 {
232 return None;
233 }
234
235 let owner = parts[0].to_string();
236 let repo = parts[1].trim_end_matches(".git").to_string();
237
238 if owner.is_empty() || repo.is_empty() {
239 return None;
240 }
241
242 Some((owner, repo))
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use tempfile::TempDir;
249
250 #[test]
251 fn test_parse_github_url_basic() {
252 let result = parse_github_url("https://github.com/serde-rs/serde");
253 assert_eq!(result, Some(("serde-rs".into(), "serde".into())));
254 }
255
256 #[test]
257 fn test_parse_github_url_with_git_suffix() {
258 let result = parse_github_url("https://github.com/tokio-rs/tokio.git");
259 assert_eq!(result, Some(("tokio-rs".into(), "tokio".into())));
260 }
261
262 #[test]
263 fn test_parse_github_url_with_path() {
264 let result = parse_github_url("https://github.com/dtolnay/anyhow/tree/main");
265 assert_eq!(result, Some(("dtolnay".into(), "anyhow".into())));
266 }
267
268 #[test]
269 fn test_parse_github_url_trailing_slash() {
270 let result = parse_github_url("https://github.com/clap-rs/clap/");
271 assert_eq!(result, Some(("clap-rs".into(), "clap".into())));
272 }
273
274 #[test]
275 fn test_parse_github_url_not_github() {
276 assert!(parse_github_url("https://gitlab.com/foo/bar").is_none());
277 assert!(parse_github_url("https://crates.io/crates/serde").is_none());
278 }
279
280 #[test]
281 fn test_parse_github_url_too_short() {
282 assert!(parse_github_url("https://github.com/just-user").is_none());
283 assert!(parse_github_url("https://github.com/").is_none());
284 }
285
286 #[test]
287 fn test_cache_entry_fresh() {
288 let entry = CacheEntry::new("some data".to_string());
289 assert!(entry.is_fresh());
290 }
291
292 #[test]
293 fn test_cache_entry_stale() {
294 let entry = CacheEntry {
295 data: "old data".to_string(),
296 fetched_at: 0, };
298 assert!(!entry.is_fresh());
299 }
300
301 #[test]
302 fn test_cache_entry_roundtrip() {
303 let intel = CrateIntel {
304 name: "serde".into(),
305 latest_version: "1.0.228".into(),
306 downloads: 100_000_000,
307 recent_downloads: Some(5_000_000),
308 last_updated: "2026-01-15T12:00:00Z".into(),
309 repository_url: Some("https://github.com/serde-rs/serde".into()),
310 description: Some("A serialization framework".into()),
311 };
312 let entry = CacheEntry::new(intel);
313 let json = serde_json::to_string(&entry).unwrap();
314 let roundtrip: CacheEntry<CrateIntel> = serde_json::from_str(&json).unwrap();
315 assert_eq!(roundtrip.data.name, "serde");
316 assert_eq!(roundtrip.data.downloads, 100_000_000);
317 assert!(roundtrip.is_fresh());
318 }
319
320 #[test]
321 fn test_cache_disk_write_and_read() {
322 let tmp = TempDir::new().unwrap();
323 let cache_path = tmp.path().join("test_crate.json");
324
325 let intel = CrateIntel {
326 name: "test_crate".into(),
327 latest_version: "0.1.0".into(),
328 downloads: 42,
329 recent_downloads: None,
330 last_updated: "2026-02-27T00:00:00Z".into(),
331 repository_url: None,
332 description: None,
333 };
334
335 let entry = CacheEntry::new(intel);
337 let json = serde_json::to_string_pretty(&entry).unwrap();
338 fs::write(&cache_path, &json).unwrap();
339
340 let contents = fs::read_to_string(&cache_path).unwrap();
342 let loaded: CacheEntry<CrateIntel> = serde_json::from_str(&contents).unwrap();
343 assert_eq!(loaded.data.name, "test_crate");
344 assert!(loaded.is_fresh());
345 }
346
347 #[test]
348 fn test_fetch_bulk_intel() {
349 let tmp = TempDir::new().unwrap();
350
351 let mut client = IntelClient::new().unwrap();
352 client.cache_dir = Some(tmp.path().to_path_buf());
353
354 let intel = CrateIntel {
356 name: "test_success".into(),
357 latest_version: "1.0.0".into(),
358 downloads: 100,
359 recent_downloads: None,
360 last_updated: "2026-02-27T00:00:00Z".into(),
361 repository_url: None,
362 description: None,
363 };
364 let entry = CacheEntry::new(intel.clone());
365 let json = serde_json::to_string_pretty(&entry).unwrap();
366 fs::write(tmp.path().join("test_success.json"), json).unwrap();
367
368 let results = client.fetch_bulk_intel(&["test_success", "test_failure_not_exist_abc123"]);
370
371 assert_eq!(results.len(), 1);
373 assert!(results.contains_key("test_success"));
374 assert_eq!(results.get("test_success").unwrap().name, "test_success");
375 }
376
377 #[test]
379 #[ignore]
380 fn test_live_fetch_serde() {
381 let client = IntelClient::new().expect("client should init");
382 let intel = client
383 .fetch_crate_intel("serde")
384 .expect("should fetch serde");
385 assert_eq!(intel.name, "serde");
386 assert!(intel.downloads > 0);
387 println!(
388 "serde: v{}, {} downloads",
389 intel.latest_version, intel.downloads
390 );
391 }
392
393 #[test]
395 #[ignore]
396 fn test_live_github_serde() {
397 let client = IntelClient::new().expect("client should init");
398 let activity = client
399 .fetch_github_activity("https://github.com/serde-rs/serde")
400 .expect("should get activity");
401 assert!(activity.stars > 0);
402 println!(
403 "serde: {} stars, archived={}",
404 activity.stars, activity.is_archived
405 );
406 }
407}