rustlens_lib/
crates_io.rs1use std::time::Duration;
5
6#[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#[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
29const MAX_RESPONSE_BYTES: u64 = 1024 * 1024;
31const MAX_GITHUB_RESPONSE_BYTES: u64 = 64 * 1024;
33const TIMEOUT: Duration = Duration::from_secs(15);
35const USER_AGENT: &str =
37 "Rustlens/0.2 (Rust code inspector; https://github.com/yashksaini-coder/vizier)";
38
39fn 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
54fn 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
114pub 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}