1pub fn with_default_scheme(url: &str) -> String {
5 if !url.contains("://")
6 && !is_local_path(url)
7 && !is_ssh_shorthand(url)
8 && url.contains('/')
9 && url.contains('.')
10 {
11 format!("https://{url}")
12 } else {
13 url.to_string()
14 }
15}
16
17fn is_local_path(url: &str) -> bool {
21 let bytes = url.as_bytes();
22 if url.starts_with("file:")
23 || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
24 {
25 return true;
26 }
27 match url.split_once(':') {
28 Some((before_colon, _)) => before_colon.contains('/'),
29 None => url.starts_with(['/', '.', '~']),
30 }
31}
32
33pub fn is_ssh_shorthand(url: &str) -> bool {
37 if url.contains("://") || is_local_path(url) {
38 return false;
39 }
40 let Some((authority, path)) = url.split_once(':') else {
41 return false;
42 };
43 if path.is_empty() || authority.contains(['/', '\\']) {
44 return false;
45 }
46 match authority.split_once('@') {
47 Some((user, host)) => !user.is_empty() && !host.is_empty(),
48 None => {
49 let (first, rest) = path.split_once('/').unwrap_or((path, ""));
50 let is_port =
51 !rest.is_empty() && !first.is_empty() && first.bytes().all(|b| b.is_ascii_digit());
52 !authority.is_empty() && !is_port
53 }
54 }
55}
56
57pub fn canonicalize_git_url(url: &str) -> String {
59 let mut s = url.to_string();
60
61 for prefix in &["https://", "http://", "ssh://", "git://"] {
62 if let Some(rest) = s.strip_prefix(prefix) {
63 s = rest.to_string();
64 break;
65 }
66 }
67
68 if let Some(at_pos) = s.find('@') {
70 let slash_pos = s.find('/').unwrap_or(s.len());
71 if at_pos < slash_pos {
72 s = s[at_pos + 1..].to_string();
73 }
74 }
75
76 if let Some(colon_pos) = s.find(':') {
77 let before_colon = &s[..colon_pos];
78 let after_colon = &s[colon_pos + 1..];
79 if !before_colon.contains('/') && !after_colon.starts_with("//") {
83 let port_candidate = after_colon.split('/').next().unwrap_or("");
84 let is_port =
85 !port_candidate.is_empty() && port_candidate.chars().all(|c| c.is_ascii_digit());
86 if !is_port {
87 s.replace_range(colon_pos..colon_pos + 1, "/");
88 }
89 }
90 }
91
92 if let Some(rest) = s.strip_suffix(".git") {
93 s = rest.to_string();
94 }
95
96 if let Some(rest) = s.strip_suffix('/') {
97 s = rest.to_string();
98 }
99
100 if let Some(slash_pos) = s.find('/') {
102 let host = s[..slash_pos].to_ascii_lowercase();
103 s = format!("{host}{}", &s[slash_pos..]);
104 } else {
105 s = s.to_ascii_lowercase();
106 }
107
108 s
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn https_form() {
117 assert_eq!(
118 canonicalize_git_url("https://github.com/foo/bar"),
119 "github.com/foo/bar"
120 );
121 }
122
123 #[test]
124 fn http_form() {
125 assert_eq!(
126 canonicalize_git_url("http://gitlab.com/org/repo"),
127 "gitlab.com/org/repo"
128 );
129 }
130
131 #[test]
132 fn ssh_url_form() {
133 assert_eq!(
134 canonicalize_git_url("ssh://git@github.com/foo/bar"),
135 "github.com/foo/bar"
136 );
137 }
138
139 #[test]
140 fn git_protocol_form() {
141 assert_eq!(
142 canonicalize_git_url("git://github.com/foo/bar"),
143 "github.com/foo/bar"
144 );
145 }
146
147 #[test]
148 fn scp_form_git_at() {
149 assert_eq!(
150 canonicalize_git_url("git@github.com:foo/bar.git"),
151 "github.com/foo/bar"
152 );
153 }
154
155 #[test]
156 fn bare_domain_form() {
157 assert_eq!(
158 canonicalize_git_url("github.com/meridian-flow/meridian-base"),
159 "github.com/meridian-flow/meridian-base"
160 );
161 }
162
163 #[test]
164 fn ssh_and_https_converge() {
165 let https = canonicalize_git_url("https://github.com/foo/bar");
166 let ssh = canonicalize_git_url("git@github.com:foo/bar.git");
167 let ssh_url = canonicalize_git_url("ssh://git@github.com/foo/bar");
168 assert_eq!(https, ssh);
169 assert_eq!(https, ssh_url);
170 }
171
172 #[test]
173 fn strips_git_suffix_https() {
174 assert_eq!(
175 canonicalize_git_url("https://github.com/foo/bar.git"),
176 "github.com/foo/bar"
177 );
178 }
179
180 #[test]
181 fn strips_git_suffix_bare() {
182 assert_eq!(
183 canonicalize_git_url("github.com/foo/bar.git"),
184 "github.com/foo/bar"
185 );
186 }
187
188 #[test]
189 fn strips_trailing_slash() {
190 assert_eq!(
191 canonicalize_git_url("https://github.com/foo/bar/"),
192 "github.com/foo/bar"
193 );
194 }
195
196 #[test]
197 fn lowercases_host_only() {
198 assert_eq!(
199 canonicalize_git_url("GITHUB.COM/Foo/Bar"),
200 "github.com/Foo/Bar"
201 );
202 }
203
204 #[test]
205 fn lowercases_host_in_https_url() {
206 assert_eq!(
207 canonicalize_git_url("https://GITHUB.COM/Foo/Bar"),
208 "github.com/Foo/Bar"
209 );
210 }
211
212 #[test]
213 fn strips_userinfo_user_password() {
214 assert_eq!(
215 canonicalize_git_url("https://user:pass@github.com/foo/bar"),
216 "github.com/foo/bar"
217 );
218 }
219
220 #[test]
221 fn strips_userinfo_bare_user() {
222 assert_eq!(
223 canonicalize_git_url("https://user@github.com/foo/bar"),
224 "github.com/foo/bar"
225 );
226 }
227
228 #[test]
229 fn preserves_explicit_port_in_url_form() {
230 assert_eq!(
231 canonicalize_git_url("git://gitlab.localtest.me:19424/group/pkg.git"),
232 "gitlab.localtest.me:19424/group/pkg"
233 );
234 }
235
236 #[test]
237 fn preserves_explicit_port_in_https() {
238 assert_eq!(
239 canonicalize_git_url("https://git.example.com:8443/org/repo"),
240 "git.example.com:8443/org/repo"
241 );
242 }
243
244 #[test]
245 fn scp_colon_converted_to_slash() {
246 assert_eq!(
247 canonicalize_git_url("git@github.com:org/repo.git"),
248 "github.com/org/repo"
249 );
250 }
251
252 #[test]
253 fn scp_digit_leading_path_segment() {
254 assert_eq!(
255 canonicalize_git_url("git@example.com:123team/repo.git"),
256 "example.com/123team/repo"
257 );
258 }
259
260 #[test]
261 fn scp_digit_leading_path_segment_no_git_suffix() {
262 assert_eq!(
263 canonicalize_git_url("git@example.com:9front/repo"),
264 "example.com/9front/repo"
265 );
266 }
267
268 #[test]
269 fn port_only_digits_is_preserved_https() {
270 assert_eq!(
271 canonicalize_git_url("https://gitlab.com:8443/org/repo"),
272 "gitlab.com:8443/org/repo"
273 );
274 }
275
276 #[test]
277 fn port_only_digits_is_preserved_git_protocol() {
278 assert_eq!(
279 canonicalize_git_url("git://custom.host:19424/group/pkg.git"),
280 "custom.host:19424/group/pkg"
281 );
282 }
283
284 #[test]
285 fn subgroup_path_preserved() {
286 assert_eq!(
287 canonicalize_git_url("https://gitlab.com/group/subgroup/repo"),
288 "gitlab.com/group/subgroup/repo"
289 );
290 }
291
292 #[test]
293 fn subgroup_path_ssh_preserved() {
294 assert_eq!(
295 canonicalize_git_url("git@gitlab.com:group/subgroup/repo.git"),
296 "gitlab.com/group/subgroup/repo"
297 );
298 }
299
300 #[test]
301 fn bare_host_no_path_lowercased() {
302 assert_eq!(canonicalize_git_url("GITHUB.COM"), "github.com");
303 }
304
305 #[test]
306 fn idempotent_on_already_canonical_form() {
307 let canonical = "github.com/foo/bar";
308 assert_eq!(canonicalize_git_url(canonical), canonical);
309 }
310
311 #[test]
312 fn default_scheme_table() {
313 let unchanged = [
314 "git@host:/srv/repo.git",
315 "git@host.example:org/repo.git",
316 "git@host:org/repo",
317 "github.com:org/repo",
318 "example.com:123team/repo",
319 "host:~user/repo",
320 "~user@host:repo",
321 "./a:b",
322 "~/x:y",
323 "[::1]:8080/org/repo.git",
324 "localhost:8080/repo",
325 "C:/x/repo.d",
326 "C:\\x\\repo",
327 "D:repo.git/x",
328 "./rel/x.git",
329 "../up/x.git",
330 "/abs/x.y/repo",
331 "~/x.y/repo",
332 "file:/x.y/r",
333 "file:///x.y/r",
334 "ssh://git@github.com/org/repo",
335 "https://github.com/org/repo",
336 "org/repo",
337 ];
338 for url in unchanged {
339 assert_eq!(with_default_scheme(url), url, "{url}");
340 }
341 assert_eq!(
342 with_default_scheme("github.com/org/repo"),
343 "https://github.com/org/repo"
344 );
345 assert_eq!(
346 with_default_scheme("example.com:8080/org/repo"),
347 "https://example.com:8080/org/repo"
348 );
349 }
350
351 #[test]
352 fn ssh_shorthand_table() {
353 for url in [
354 "git@host:/srv/repo.git",
355 "git@host:org/repo",
356 "git@host:8080/repo",
357 "github.com:org/repo",
358 "example.com:123team/repo",
359 "host:/srv/repo.git",
360 "host:~user/repo",
361 "~user@host:repo",
362 ] {
363 assert!(is_ssh_shorthand(url), "{url}");
364 }
365 for url in [
366 "example.com:8080/org/repo",
367 "git@host:",
368 "@host:org/repo",
369 "ssh://git@host/org/repo",
370 "/srv/repo:x",
371 "./a:b",
372 "~/x:y",
373 "C:/x",
374 "\\\\?\\C:\\x",
375 "file:/x",
376 "org/repo",
377 ] {
378 assert!(!is_ssh_shorthand(url), "{url}");
379 }
380 }
381}