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}