Skip to main content

bytes_radar/net/providers/
github.rs

1use 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}