Skip to main content

cargo_bless/
intel.rs

1//! Live intelligence layer — fetches metadata from crates.io and GitHub
2//! to assess freshness, popularity, and maintenance status.
3//!
4//! All network operations are **non-fatal**: failures are logged and the
5//! tool continues with whatever data it has.
6
7use 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; // 1 hour
22
23// ── Public types ─────────────────────────────────────────────────────
24
25/// Live metadata for a single crate.
26#[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/// GitHub repository activity summary.
38#[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/// Cache wrapper that tracks when data was fetched.
47#[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
71// ── IntelClient ──────────────────────────────────────────────────────
72
73/// Client for fetching live dependency intelligence.
74pub struct IntelClient {
75    client: crates_io_api::SyncClient,
76    http: reqwest::blocking::Client,
77    cache_dir: Option<PathBuf>,
78}
79
80impl IntelClient {
81    /// Create a new IntelClient with crates.io API access and disk cache.
82    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    /// Fetch live intel for a crate. Checks disk cache first (1hr TTL).
114    pub fn fetch_crate_intel(&self, name: &str) -> Result<CrateIntel> {
115        // Check cache
116        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        // Cache miss or stale — fetch from crates.io
132        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        // Write to cache (best-effort)
155        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    /// Fetch GitHub activity for a repository URL.
166    /// Returns None if the URL is not a GitHub URL or if the fetch fails.
167    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    /// Fetch intel for all unique crate names, returning what we can get.
190    /// Failures for individual crates are silently skipped.
191    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                    // Non-fatal: skip this crate
200                }
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
215// ── Helpers ──────────────────────────────────────────────────────────
216
217/// Parse a GitHub URL into (owner, repo).
218/// Supports: https://github.com/owner/repo, https://github.com/owner/repo.git,
219/// https://github.com/owner/repo/tree/main, etc.
220pub fn parse_github_url(url: &str) -> Option<(String, String)> {
221    let url = url.trim().trim_end_matches('/');
222
223    // Find the github.com part
224    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, // epoch = definitely stale
297        };
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        // Write
336        let entry = CacheEntry::new(intel);
337        let json = serde_json::to_string_pretty(&entry).unwrap();
338        fs::write(&cache_path, &json).unwrap();
339
340        // Read back
341        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        // Inject successful cache hit
355        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        // Fetch one that succeeds and one that fails (cache miss and fake crate)
369        let results = client.fetch_bulk_intel(&["test_success", "test_failure_not_exist_abc123"]);
370
371        // Validate
372        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    /// Live network test — run with `cargo test -- --ignored`
378    #[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    /// Live GitHub test — run with `cargo test -- --ignored`
394    #[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}