use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
const USER_AGENT: &str = concat!(
"cargo-bless/",
env!("CARGO_PKG_VERSION"),
" (https://github.com/Ruffian-L/cargo-bless)"
);
const CACHE_TTL_SECS: u64 = 3600;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrateIntel {
pub name: String,
pub latest_version: String,
pub downloads: u64,
pub recent_downloads: Option<u64>,
pub last_updated: String,
pub repository_url: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubActivity {
pub last_push: String,
pub stars: u64,
pub is_archived: bool,
pub open_issues: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct CacheEntry<T> {
data: T,
fetched_at: u64,
}
impl<T> CacheEntry<T> {
fn is_fresh(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now.saturating_sub(self.fetched_at) < CACHE_TTL_SECS
}
fn new(data: T) -> Self {
let fetched_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self { data, fetched_at }
}
}
pub struct IntelClient {
client: crates_io_api::SyncClient,
http: reqwest::blocking::Client,
cache_dir: Option<PathBuf>,
}
impl IntelClient {
pub fn new() -> Result<Self> {
let client = crates_io_api::SyncClient::new(USER_AGENT, Duration::from_secs(1))
.context("failed to create crates.io client")?;
let http = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(10))
.build()
.context("failed to create GitHub HTTP client")?;
let mut cache_dir = ProjectDirs::from("rs", "", "cargo-bless")
.map(|dirs| dirs.cache_dir().to_path_buf())
.filter(|path| !path.as_os_str().is_empty());
if let Some(dir) = &cache_dir {
if let Err(err) = fs::create_dir_all(dir) {
eprintln!(
"⚠️ Could not create cache directory at {}: {}. Continuing without cache.",
dir.display(),
err
);
cache_dir = None;
}
}
Ok(Self {
client,
http,
cache_dir,
})
}
pub fn fetch_crate_intel(&self, name: &str) -> Result<CrateIntel> {
let cache_path = self
.cache_dir
.as_ref()
.map(|dir| dir.join(format!("{}.json", name)));
if let Some(path) = &cache_path {
if let Ok(contents) = fs::read_to_string(path) {
if let Ok(entry) = serde_json::from_str::<CacheEntry<CrateIntel>>(&contents) {
if entry.is_fresh() {
return Ok(entry.data);
}
}
}
}
let response = self
.client
.get_crate(name)
.with_context(|| format!("failed to fetch crate info for '{}'", name))?;
let crate_data = &response.crate_data;
let latest_version = response
.versions
.first()
.map(|v| v.num.clone())
.unwrap_or_else(|| crate_data.max_version.clone());
let intel = CrateIntel {
name: name.to_string(),
latest_version,
downloads: crate_data.downloads,
recent_downloads: crate_data.recent_downloads,
last_updated: crate_data.updated_at.to_string(),
repository_url: crate_data.repository.clone(),
description: crate_data.description.clone(),
};
if let Some(path) = &cache_path {
let entry = CacheEntry::new(intel.clone());
if let Ok(json) = serde_json::to_string_pretty(&entry) {
let _ = fs::write(path, json);
}
}
Ok(intel)
}
pub fn fetch_github_activity(&self, repo_url: &str) -> Option<GitHubActivity> {
let (owner, repo) = parse_github_url(repo_url)?;
let url = format!("https://api.github.com/repos/{owner}/{repo}");
let repo_info = self
.http
.get(url)
.send()
.ok()?
.error_for_status()
.ok()?
.json::<GitHubRepoResponse>()
.ok()?;
Some(GitHubActivity {
last_push: repo_info.pushed_at.unwrap_or_else(|| "unknown".into()),
stars: repo_info.stargazers_count.unwrap_or(0),
is_archived: repo_info.archived.unwrap_or(false),
open_issues: repo_info.open_issues_count.unwrap_or(0),
})
}
pub fn fetch_bulk_intel(&self, crate_names: &[&str]) -> HashMap<String, CrateIntel> {
let mut intel = HashMap::new();
for name in crate_names {
match self.fetch_crate_intel(name) {
Ok(info) => {
intel.insert(name.to_string(), info);
}
Err(_) => {
}
}
}
intel
}
}
#[derive(Debug, Deserialize)]
struct GitHubRepoResponse {
pushed_at: Option<String>,
stargazers_count: Option<u64>,
archived: Option<bool>,
open_issues_count: Option<u64>,
}
pub fn parse_github_url(url: &str) -> Option<(String, String)> {
let url = url.trim().trim_end_matches('/');
let after_github = if let Some(pos) = url.find("github.com/") {
&url[pos + "github.com/".len()..]
} else {
return None;
};
let parts: Vec<&str> = after_github.splitn(3, '/').collect();
if parts.len() < 2 {
return None;
}
let owner = parts[0].to_string();
let repo = parts[1].trim_end_matches(".git").to_string();
if owner.is_empty() || repo.is_empty() {
return None;
}
Some((owner, repo))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_parse_github_url_basic() {
let result = parse_github_url("https://github.com/serde-rs/serde");
assert_eq!(result, Some(("serde-rs".into(), "serde".into())));
}
#[test]
fn test_parse_github_url_with_git_suffix() {
let result = parse_github_url("https://github.com/tokio-rs/tokio.git");
assert_eq!(result, Some(("tokio-rs".into(), "tokio".into())));
}
#[test]
fn test_parse_github_url_with_path() {
let result = parse_github_url("https://github.com/dtolnay/anyhow/tree/main");
assert_eq!(result, Some(("dtolnay".into(), "anyhow".into())));
}
#[test]
fn test_parse_github_url_trailing_slash() {
let result = parse_github_url("https://github.com/clap-rs/clap/");
assert_eq!(result, Some(("clap-rs".into(), "clap".into())));
}
#[test]
fn test_parse_github_url_not_github() {
assert!(parse_github_url("https://gitlab.com/foo/bar").is_none());
assert!(parse_github_url("https://crates.io/crates/serde").is_none());
}
#[test]
fn test_parse_github_url_too_short() {
assert!(parse_github_url("https://github.com/just-user").is_none());
assert!(parse_github_url("https://github.com/").is_none());
}
#[test]
fn test_cache_entry_fresh() {
let entry = CacheEntry::new("some data".to_string());
assert!(entry.is_fresh());
}
#[test]
fn test_cache_entry_stale() {
let entry = CacheEntry {
data: "old data".to_string(),
fetched_at: 0, };
assert!(!entry.is_fresh());
}
#[test]
fn test_cache_entry_roundtrip() {
let intel = CrateIntel {
name: "serde".into(),
latest_version: "1.0.228".into(),
downloads: 100_000_000,
recent_downloads: Some(5_000_000),
last_updated: "2026-01-15T12:00:00Z".into(),
repository_url: Some("https://github.com/serde-rs/serde".into()),
description: Some("A serialization framework".into()),
};
let entry = CacheEntry::new(intel);
let json = serde_json::to_string(&entry).unwrap();
let roundtrip: CacheEntry<CrateIntel> = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip.data.name, "serde");
assert_eq!(roundtrip.data.downloads, 100_000_000);
assert!(roundtrip.is_fresh());
}
#[test]
fn test_cache_disk_write_and_read() {
let tmp = TempDir::new().unwrap();
let cache_path = tmp.path().join("test_crate.json");
let intel = CrateIntel {
name: "test_crate".into(),
latest_version: "0.1.0".into(),
downloads: 42,
recent_downloads: None,
last_updated: "2026-02-27T00:00:00Z".into(),
repository_url: None,
description: None,
};
let entry = CacheEntry::new(intel);
let json = serde_json::to_string_pretty(&entry).unwrap();
fs::write(&cache_path, &json).unwrap();
let contents = fs::read_to_string(&cache_path).unwrap();
let loaded: CacheEntry<CrateIntel> = serde_json::from_str(&contents).unwrap();
assert_eq!(loaded.data.name, "test_crate");
assert!(loaded.is_fresh());
}
#[test]
fn test_fetch_bulk_intel() {
let tmp = TempDir::new().unwrap();
let mut client = IntelClient::new().unwrap();
client.cache_dir = Some(tmp.path().to_path_buf());
let intel = CrateIntel {
name: "test_success".into(),
latest_version: "1.0.0".into(),
downloads: 100,
recent_downloads: None,
last_updated: "2026-02-27T00:00:00Z".into(),
repository_url: None,
description: None,
};
let entry = CacheEntry::new(intel.clone());
let json = serde_json::to_string_pretty(&entry).unwrap();
fs::write(tmp.path().join("test_success.json"), json).unwrap();
let results = client.fetch_bulk_intel(&["test_success", "test_failure_not_exist_abc123"]);
assert_eq!(results.len(), 1);
assert!(results.contains_key("test_success"));
assert_eq!(results.get("test_success").unwrap().name, "test_success");
}
#[test]
#[ignore]
fn test_live_fetch_serde() {
let client = IntelClient::new().expect("client should init");
let intel = client
.fetch_crate_intel("serde")
.expect("should fetch serde");
assert_eq!(intel.name, "serde");
assert!(intel.downloads > 0);
println!(
"serde: v{}, {} downloads",
intel.latest_version, intel.downloads
);
}
#[test]
#[ignore]
fn test_live_github_serde() {
let client = IntelClient::new().expect("client should init");
let activity = client
.fetch_github_activity("https://github.com/serde-rs/serde")
.expect("should get activity");
assert!(activity.stars > 0);
println!(
"serde: {} stars, archived={}",
activity.stars, activity.is_archived
);
}
}