bytes_radar/net/providers/
github.rs1use crate::net::traits::{GitProvider, ParsedRepository, ProviderConfig};
2use async_trait::async_trait;
3use reqwest::Client;
4use serde::Deserialize;
5
6#[derive(Deserialize)]
7struct GitHubRepoInfo {
8 default_branch: String,
9}
10
11pub struct GitHubProvider {
12 token: Option<String>,
13}
14
15impl GitHubProvider {
16 pub fn new() -> Self {
17 Self { token: None }
18 }
19}
20
21#[async_trait]
22impl GitProvider for GitHubProvider {
23 fn name(&self) -> &'static str {
24 "github"
25 }
26
27 fn can_handle(&self, url: &str) -> bool {
28 url.contains("github.com")
29 }
30
31 fn parse_url(&self, url: &str) -> Option<ParsedRepository> {
32 if !self.can_handle(url) {
33 return None;
34 }
35
36 let url = url.trim_end_matches('/');
37
38 if url.contains("/tree/") {
39 return self.parse_tree_url(url);
40 }
41
42 if url.contains("/commit/") {
43 return self.parse_commit_url(url);
44 }
45
46 self.parse_basic_url(url)
47 }
48
49 fn build_download_urls(&self, parsed: &ParsedRepository) -> Vec<String> {
50 let mut urls = Vec::new();
51
52 if let Some(ref branch_or_commit) = parsed.branch_or_commit {
53 if parsed.is_commit {
54 urls.push(format!(
55 "https://github.com/{}/{}/archive/{}.tar.gz",
56 parsed.owner, parsed.repo, branch_or_commit
57 ));
58 } else {
59 urls.push(format!(
60 "https://github.com/{}/{}/archive/refs/heads/{}.tar.gz",
61 parsed.owner, parsed.repo, branch_or_commit
62 ));
63 urls.push(format!(
64 "https://github.com/{}/{}/archive/refs/tags/{}.tar.gz",
65 parsed.owner, parsed.repo, branch_or_commit
66 ));
67 }
68 }
69
70 urls
71 }
72
73 async fn get_default_branch(
74 &self,
75 client: &Client,
76 parsed: &ParsedRepository,
77 ) -> Option<String> {
78 #[cfg(not(target_arch = "wasm32"))]
79 {
80 let api_url = format!(
81 "https://api.github.com/repos/{}/{}",
82 parsed.owner, parsed.repo
83 );
84
85 let mut request = client.get(&api_url);
86
87 if let Some(ref token) = self.token {
88 request = request.header("Authorization", format!("token {}", token));
89 }
90
91 match request.send().await {
92 Ok(response) => {
93 if response.status().is_success() {
94 match response.json::<GitHubRepoInfo>().await {
95 Ok(repo_info) => {
96 #[cfg(feature = "cli")]
97 log::debug!(
98 "GitHub API: Found default branch '{}' for {}/{}",
99 repo_info.default_branch,
100 parsed.owner,
101 parsed.repo
102 );
103 Some(repo_info.default_branch)
104 }
105 Err(_) => {
106 #[cfg(feature = "cli")]
107 log::debug!(
108 "GitHub API: Failed to parse response for {}/{}",
109 parsed.owner,
110 parsed.repo
111 );
112 None
113 }
114 }
115 } else {
116 #[cfg(feature = "cli")]
117 log::debug!(
118 "GitHub API: Request failed with status {} for {}/{}",
119 response.status(),
120 parsed.owner,
121 parsed.repo
122 );
123 None
124 }
125 }
126 Err(_) => {
127 #[cfg(feature = "cli")]
128 log::debug!(
129 "GitHub API: Network error for {}/{}",
130 parsed.owner,
131 parsed.repo
132 );
133 None
134 }
135 }
136 }
137
138 #[cfg(target_arch = "wasm32")]
139 None
140 }
141
142 fn apply_config(&mut self, config: &ProviderConfig) {
143 self.token = config.credentials.get("token").cloned();
144 }
145
146 fn get_project_name(&self, url: &str) -> String {
147 if let Some(parsed) = self.parse_url(url) {
148 return parsed.project_name;
149 }
150
151 if let Some(filename) = url.split('/').next_back() {
152 if filename.ends_with(".tar.gz") {
153 return filename.trim_end_matches(".tar.gz").to_string();
154 }
155 if filename.ends_with(".tgz") {
156 return filename.trim_end_matches(".tgz").to_string();
157 }
158 return filename.to_string();
159 }
160
161 "github-project".to_string()
162 }
163}
164
165impl GitHubProvider {
166 fn parse_tree_url(&self, url: &str) -> Option<ParsedRepository> {
167 let parts: Vec<&str> = url.split('/').collect();
168 if let Some(tree_pos) = parts.iter().position(|&x| x == "tree") {
169 if tree_pos + 1 < parts.len() && tree_pos >= 2 {
170 let owner = parts[tree_pos - 2].to_string();
171 let repo = parts[tree_pos - 1].to_string();
172 let branch = parts[tree_pos + 1].to_string();
173
174 return Some(
175 ParsedRepository::new(owner, repo)
176 .with_branch(branch)
177 .with_host("github.com".to_string()),
178 );
179 }
180 }
181 None
182 }
183
184 fn parse_commit_url(&self, url: &str) -> Option<ParsedRepository> {
185 let parts: Vec<&str> = url.split('/').collect();
186 if let Some(commit_pos) = parts.iter().position(|&x| x == "commit") {
187 if commit_pos + 1 < parts.len() && commit_pos >= 2 {
188 let owner = parts[commit_pos - 2].to_string();
189 let repo = parts[commit_pos - 1].to_string();
190 let commit = parts[commit_pos + 1].to_string();
191
192 return Some(
193 ParsedRepository::new(owner, repo)
194 .with_commit(commit)
195 .with_host("github.com".to_string()),
196 );
197 }
198 }
199 None
200 }
201
202 fn parse_basic_url(&self, url: &str) -> Option<ParsedRepository> {
203 let parts: Vec<&str> = url.split('/').collect();
204 if let Some(github_pos) = parts.iter().position(|&x| x == "github.com") {
205 if github_pos + 2 < parts.len() {
206 let owner = parts[github_pos + 1].to_string();
207 let repo = parts[github_pos + 2].to_string();
208
209 return Some(
210 ParsedRepository::new(owner, repo).with_host("github.com".to_string()),
211 );
212 }
213 }
214
215 if let Some(stripped) = url.strip_prefix("https://github.com/") {
216 let parts: Vec<&str> = stripped.split('/').collect();
217 if parts.len() >= 2 {
218 let owner = parts[0].to_string();
219 let repo = parts[1].to_string();
220
221 return Some(
222 ParsedRepository::new(owner, repo).with_host("github.com".to_string()),
223 );
224 }
225 }
226
227 None
228 }
229}
230
231impl Default for GitHubProvider {
232 fn default() -> Self {
233 Self::new()
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn test_can_handle() {
243 let provider = GitHubProvider::new();
244 assert!(provider.can_handle("https://github.com/user/repo"));
245 assert!(provider.can_handle("https://github.com/user/repo/tree/main"));
246 assert!(!provider.can_handle("https://gitlab.com/user/repo"));
247 }
248
249 #[test]
250 fn test_parse_basic_url() {
251 let provider = GitHubProvider::new();
252
253 let parsed = provider.parse_url("https://github.com/user/repo").unwrap();
254 assert_eq!(parsed.owner, "user");
255 assert_eq!(parsed.repo, "repo");
256 assert_eq!(parsed.project_name, "repo@main");
257 assert_eq!(parsed.branch_or_commit, None);
258 assert!(!parsed.is_commit);
259 assert_eq!(parsed.host.as_ref().unwrap(), "github.com");
260 }
261
262 #[test]
263 fn test_parse_tree_url() {
264 let provider = GitHubProvider::new();
265
266 let parsed = provider
267 .parse_url("https://github.com/user/repo/tree/develop")
268 .unwrap();
269 assert_eq!(parsed.owner, "user");
270 assert_eq!(parsed.repo, "repo");
271 assert_eq!(parsed.project_name, "repo@develop");
272 assert_eq!(parsed.branch_or_commit, Some("develop".to_string()));
273 assert!(!parsed.is_commit);
274 }
275
276 #[test]
277 fn test_parse_commit_url() {
278 let provider = GitHubProvider::new();
279
280 let parsed = provider
281 .parse_url("https://github.com/user/repo/commit/abc1234567890")
282 .unwrap();
283 assert_eq!(parsed.owner, "user");
284 assert_eq!(parsed.repo, "repo");
285 assert_eq!(parsed.project_name, "repo@abc1234");
286 assert_eq!(parsed.branch_or_commit, Some("abc1234567890".to_string()));
287 assert!(parsed.is_commit);
288 }
289
290 #[test]
291 fn test_build_download_urls() {
292 let provider = GitHubProvider::new();
293
294 let parsed = ParsedRepository::new("user".to_string(), "repo".to_string())
295 .with_branch("main".to_string());
296
297 let urls = provider.build_download_urls(&parsed);
298 assert!(urls
299 .contains(&"https://github.com/user/repo/archive/refs/heads/main.tar.gz".to_string()));
300 assert!(urls
301 .contains(&"https://github.com/user/repo/archive/refs/tags/main.tar.gz".to_string()));
302 }
303}