Skip to main content

githem_core/
parser.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4pub struct ParsedGitHubUrl {
5    pub owner: String,
6    pub repo: String,
7    pub branch: Option<String>,
8    pub path: Option<String>,
9    pub url_type: GitHubUrlType,
10    pub canonical_url: String,
11}
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub enum GitHubUrlType {
15    Repository,
16    Tree,
17    Blob,
18    Raw,
19    Commit,
20    Gist,
21    GistRaw,
22    Compare,
23}
24
25pub fn parse_github_url(url: &str) -> Option<ParsedGitHubUrl> {
26    let url = url.trim().trim_end_matches('/');
27
28    if url.contains("gist.github.com") {
29        return parse_gist_url(url);
30    }
31
32    if url.contains("raw.githubusercontent.com") {
33        return parse_raw_url(url);
34    }
35
36    if let Some(path) = url
37        .strip_prefix("https://github.com/")
38        .or_else(|| url.strip_prefix("http://github.com/"))
39        .or_else(|| url.strip_prefix("github.com/"))
40    {
41        let parts: Vec<&str> = path.split('/').collect();
42        if parts.len() >= 2 {
43            let owner = parts[0].to_string();
44            let repo = parts[1].to_string();
45
46            if parts.len() == 2 {
47                return Some(ParsedGitHubUrl {
48                    owner: owner.clone(),
49                    repo: repo.clone(),
50                    branch: None,
51                    path: None,
52                    url_type: GitHubUrlType::Repository,
53                    canonical_url: format!("https://github.com/{}/{}", owner, repo),
54                });
55            }
56
57            if parts.len() >= 4 {
58                match parts[2] {
59                    "tree" | "blob" => {
60                        let all_parts = &parts[3..];
61                        if all_parts.is_empty() {
62                            return None;
63                        }
64
65                        let mut branch_end_idx = all_parts.len();
66                        for (i, part) in all_parts.iter().enumerate() {
67                            if part.contains('.') && !part.ends_with(".git") {
68                                branch_end_idx = i;
69                                break;
70                            }
71                            if matches!(
72                                *part,
73                                "src"
74                                    | "lib"
75                                    | "test"
76                                    | "tests"
77                                    | "docs"
78                                    | "bin"
79                                    | "pkg"
80                                    | "cmd"
81                                    | "internal"
82                                    | "api"
83                                    | "web"
84                                    | "client"
85                                    | "server"
86                                    | "assets"
87                                    | "public"
88                            ) {
89                                branch_end_idx = i;
90                                break;
91                            }
92                        }
93
94                        let branch = all_parts[..branch_end_idx].join("/");
95                        let path = if branch_end_idx < all_parts.len() {
96                            Some(all_parts[branch_end_idx..].join("/"))
97                        } else {
98                            None
99                        };
100
101                        return Some(ParsedGitHubUrl {
102                            owner: owner.clone(),
103                            repo: repo.clone(),
104                            branch: Some(branch),
105                            path,
106                            url_type: if parts[2] == "tree" {
107                                GitHubUrlType::Tree
108                            } else {
109                                GitHubUrlType::Blob
110                            },
111                            canonical_url: format!("https://github.com/{}/{}", owner, repo),
112                        });
113                    }
114                    "commit" => {
115                        return Some(ParsedGitHubUrl {
116                            owner: owner.clone(),
117                            repo: repo.clone(),
118                            branch: Some(parts[3].to_string()),
119                            path: None,
120                            url_type: GitHubUrlType::Commit,
121                            canonical_url: format!("https://github.com/{}/{}", owner, repo),
122                        });
123                    }
124                    "compare" => {
125                        let compare_spec = parts[3..].join("/");
126                        return Some(ParsedGitHubUrl {
127                            owner: owner.clone(),
128                            repo: repo.clone(),
129                            branch: Some(compare_spec),
130                            path: None,
131                            url_type: GitHubUrlType::Compare,
132                            canonical_url: format!("https://github.com/{}/{}", owner, repo),
133                        });
134                    }
135                    _ => {}
136                }
137            }
138        }
139    }
140
141    None
142}
143
144fn parse_gist_url(url: &str) -> Option<ParsedGitHubUrl> {
145    if let Some(path) = url
146        .strip_prefix("https://gist.github.com/")
147        .or_else(|| url.strip_prefix("http://gist.github.com/"))
148    {
149        let parts: Vec<&str> = path.split('/').collect();
150
151        if parts.len() == 1 {
152            return Some(ParsedGitHubUrl {
153                owner: "anonymous".to_string(),
154                repo: parts[0].to_string(),
155                branch: None,
156                path: None,
157                url_type: GitHubUrlType::Gist,
158                canonical_url: format!("https://gist.github.com/{}", parts[0]),
159            });
160        }
161
162        if parts.len() >= 2 {
163            return Some(ParsedGitHubUrl {
164                owner: parts[0].to_string(),
165                repo: parts[1].to_string(),
166                branch: None,
167                path: None,
168                url_type: GitHubUrlType::Gist,
169                canonical_url: format!("https://gist.github.com/{}/{}", parts[0], parts[1]),
170            });
171        }
172    }
173
174    None
175}
176
177fn parse_raw_url(url: &str) -> Option<ParsedGitHubUrl> {
178    let path = url
179        .strip_prefix("https://raw.githubusercontent.com/")
180        .or_else(|| url.strip_prefix("http://raw.githubusercontent.com/"))?;
181
182    let parts: Vec<&str> = path.split('/').collect();
183    if parts.len() >= 3 {
184        let owner = parts[0].to_string();
185        let repo = parts[1].to_string();
186        let branch = parts[2].to_string();
187        let path = if parts.len() > 3 {
188            Some(parts[3..].join("/"))
189        } else {
190            None
191        };
192
193        return Some(ParsedGitHubUrl {
194            owner: owner.clone(),
195            repo: repo.clone(),
196            branch: Some(branch),
197            path,
198            url_type: GitHubUrlType::Raw,
199            canonical_url: format!("https://github.com/{}/{}", owner, repo),
200        });
201    }
202
203    None
204}
205
206pub fn normalize_source_url(
207    source: &str,
208    branch: Option<String>,
209    path_prefix: Option<String>,
210) -> Result<(String, Option<String>, Option<String>), String> {
211    if let Some(parsed) = parse_github_url(source) {
212        let final_branch = branch.or(parsed.branch);
213        let final_path = path_prefix.or(parsed.path);
214        return Ok((parsed.canonical_url, final_branch, final_path));
215    }
216
217    if !source.contains("://") && source.matches('/').count() == 1 {
218        let parts: Vec<&str> = source.split('/').collect();
219        if parts.len() == 2 && validate_github_name(parts[0]) && validate_github_name(parts[1]) {
220            let url = format!("https://github.com/{}/{}", parts[0], parts[1]);
221            return Ok((url, branch, path_prefix));
222        }
223    }
224
225    Ok((source.to_string(), branch, path_prefix))
226}
227
228pub fn validate_github_name(name: &str) -> bool {
229    !name.is_empty()
230        && name.len() <= 39
231        && name
232            .chars()
233            .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
234        && !name.starts_with(['-', '.'])
235        && !name.ends_with(['-', '.'])
236}