Skip to main content

rustlens_lib/
crates_io.rs

1//! Fetch crate metadata from crates.io API and optional GitHub repo metrics.
2//! Uses timeout and response size limit for safety. Intended to be run from a background thread.
3
4use std::time::Duration;
5
6/// Optional GitHub repository metrics (from GitHub REST API).
7#[derive(Clone, Debug, Default)]
8pub struct GitHubRepoInfo {
9    pub stars: Option<u32>,
10    pub forks: Option<u32>,
11    pub language: Option<String>,
12    pub updated_at: Option<String>,
13    pub open_issues_count: Option<u32>,
14    pub default_branch: Option<String>,
15}
16
17/// Crate metadata from crates.io (for inspector docs view). May include GitHub metrics if repo URL is GitHub.
18#[derive(Clone, Debug)]
19pub struct CrateDocInfo {
20    pub name: String,
21    pub version: String,
22    pub description: Option<String>,
23    pub documentation: Option<String>,
24    pub homepage: Option<String>,
25    pub repository: Option<String>,
26    pub github: Option<GitHubRepoInfo>,
27}
28
29/// Max response body size (1 MiB) to avoid unbounded memory.
30const MAX_RESPONSE_BYTES: u64 = 1024 * 1024;
31/// Max GitHub API response (small JSON).
32const MAX_GITHUB_RESPONSE_BYTES: u64 = 64 * 1024;
33/// Request timeout.
34const TIMEOUT: Duration = Duration::from_secs(15);
35/// User-Agent: crates.io requires it for API requests.
36const USER_AGENT: &str =
37    "Rustlens/0.2 (Rust code inspector; https://github.com/yashksaini-coder/vizier)";
38
39/// Parse "https://github.com/owner/repo" or "https://github.com/owner/repo/" into Some(("owner", "repo")).
40fn parse_github_url(repo: &str) -> Option<(String, String)> {
41    let s = repo.trim().trim_end_matches('/');
42    let rest = s
43        .strip_prefix("https://github.com/")
44        .or_else(|| s.strip_prefix("http://github.com/"))?;
45    let mut parts = rest.splitn(2, '/');
46    let owner = parts.next()?.to_string();
47    let repo_name = parts.next()?.split('/').next()?.to_string();
48    if owner.is_empty() || repo_name.is_empty() {
49        return None;
50    }
51    Some((owner, repo_name))
52}
53
54/// Fetch repository metrics from GitHub REST API. Returns None on any error.
55/// GitHub allows 60 req/h unauthenticated; set GITHUB_TOKEN for 5000/h.
56fn fetch_github_repo_info(owner: &str, repo: &str) -> Option<GitHubRepoInfo> {
57    let url = format!("https://api.github.com/repos/{}/{}", owner, repo);
58    let client = reqwest::blocking::Client::builder()
59        .timeout(TIMEOUT)
60        .user_agent(USER_AGENT)
61        .build()
62        .ok()?;
63    let mut req = client
64        .get(&url)
65        .header("Accept", "application/vnd.github.v3+json");
66    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
67        if !token.is_empty() {
68            req = req.header("Authorization", format!("Bearer {}", token));
69        }
70    }
71    let response = req.send().ok()?;
72    if !response.status().is_success() {
73        return None;
74    }
75    let bytes = response.bytes().ok()?;
76    if bytes.len() as u64 > MAX_GITHUB_RESPONSE_BYTES {
77        return None;
78    }
79    let body: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
80    let stars = body
81        .get("stargazers_count")
82        .and_then(|v| v.as_u64())
83        .map(|n| n as u32);
84    let forks = body
85        .get("forks_count")
86        .and_then(|v| v.as_u64())
87        .map(|n| n as u32);
88    let language = body
89        .get("language")
90        .and_then(|v| v.as_str())
91        .map(String::from);
92    let updated_at = body
93        .get("updated_at")
94        .and_then(|v| v.as_str())
95        .map(String::from);
96    let open_issues_count = body
97        .get("open_issues_count")
98        .and_then(|v| v.as_u64())
99        .map(|n| n as u32);
100    let default_branch = body
101        .get("default_branch")
102        .and_then(|v| v.as_str())
103        .map(String::from);
104    Some(GitHubRepoInfo {
105        stars,
106        forks,
107        language,
108        updated_at,
109        open_issues_count,
110        default_branch,
111    })
112}
113
114/// Fetch crate info from crates.io API. Returns `None` on any error (network, parse, timeout).
115/// If the crate has a GitHub repository URL, also fetches repo metrics (stars, forks, language, etc.).
116/// Set optional `GITHUB_TOKEN` env var for higher GitHub API rate limit.
117/// Safe to call from a background thread; uses blocking HTTP with timeout and size limit.
118pub fn fetch_crate_docs(crate_name: &str) -> Option<CrateDocInfo> {
119    let url = format!("https://crates.io/api/v1/crates/{}", crate_name);
120    let client = reqwest::blocking::Client::builder()
121        .timeout(TIMEOUT)
122        .user_agent(USER_AGENT)
123        .build()
124        .ok()?;
125    let response = client
126        .get(&url)
127        .header("Accept", "application/json")
128        .send()
129        .ok()?;
130    if !response.status().is_success() {
131        return None;
132    }
133    let content_len = response.content_length().unwrap_or(0);
134    if content_len > MAX_RESPONSE_BYTES {
135        return None;
136    }
137    let body: serde_json::Value = response.json().ok()?;
138    let crate_obj = body.get("crate")?;
139    let name = crate_obj.get("name")?.as_str()?.to_string();
140    let description = crate_obj
141        .get("description")
142        .and_then(|v| v.as_str())
143        .map(String::from);
144    let documentation = crate_obj
145        .get("documentation")
146        .and_then(|v| v.as_str())
147        .map(String::from);
148    let homepage = crate_obj
149        .get("homepage")
150        .and_then(|v| v.as_str())
151        .map(String::from);
152    let repository = crate_obj
153        .get("repository")
154        .and_then(|v| v.as_str())
155        .map(String::from);
156    let version = crate_obj
157        .get("newest_version")
158        .or_else(|| crate_obj.get("max_version"))
159        .and_then(|v| v.as_str())
160        .unwrap_or("?")
161        .to_string();
162
163    let github = repository
164        .as_ref()
165        .and_then(|r| parse_github_url(r))
166        .and_then(|(owner, repo)| fetch_github_repo_info(&owner, &repo));
167
168    Some(CrateDocInfo {
169        name,
170        version,
171        description,
172        documentation,
173        homepage,
174        repository,
175        github,
176    })
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_parse_github_url() {
185        assert_eq!(
186            parse_github_url("https://github.com/rust-lang/rust"),
187            Some(("rust-lang".into(), "rust".into()))
188        );
189        assert_eq!(
190            parse_github_url("https://github.com/owner/repo/"),
191            Some(("owner".into(), "repo".into()))
192        );
193        assert_eq!(
194            parse_github_url("http://github.com/a/b"),
195            Some(("a".into(), "b".into()))
196        );
197        assert!(parse_github_url("https://gitlab.com/owner/repo").is_none());
198        assert!(parse_github_url("https://github.com/").is_none());
199        assert!(parse_github_url("").is_none());
200    }
201}