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 {
79 let mut s = url.to_string();
80
81 for prefix in &["https://", "http://", "ssh://", "git://"] {
83 if let Some(rest) = s.strip_prefix(prefix) {
84 s = rest.to_string();
85 break;
86 }
87 }
88
89 if let Some(at_pos) = s.find('@') {
93 let slash_pos = s.find('/').unwrap_or(s.len());
94 if at_pos < slash_pos {
95 s = s[at_pos + 1..].to_string();
96 }
97 }
98
99 if let Some(colon_pos) = s.find(':') {
106 let before_colon = &s[..colon_pos];
107 let after_colon = &s[colon_pos + 1..];
108 if !before_colon.contains('/') && !after_colon.starts_with("//") {
112 let port_candidate = after_colon.split('/').next().unwrap_or("");
113 let is_port =
114 !port_candidate.is_empty() && port_candidate.chars().all(|c| c.is_ascii_digit());
115 if !is_port {
116 s.replace_range(colon_pos..colon_pos + 1, "/");
118 }
119 }
120 }
121
122 if let Some(rest) = s.strip_suffix(".git") {
124 s = rest.to_string();
125 }
126
127 if let Some(rest) = s.strip_suffix('/') {
129 s = rest.to_string();
130 }
131
132 if let Some(slash_pos) = s.find('/') {
135 let host = s[..slash_pos].to_ascii_lowercase();
136 s = format!("{host}{}", &s[slash_pos..]);
137 } else {
138 s = s.to_ascii_lowercase();
140 }
141
142 s
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
152 fn https_form() {
153 assert_eq!(
154 canonicalize_git_url("https://github.com/foo/bar"),
155 "github.com/foo/bar"
156 );
157 }
158
159 #[test]
160 fn http_form() {
161 assert_eq!(
162 canonicalize_git_url("http://gitlab.com/org/repo"),
163 "gitlab.com/org/repo"
164 );
165 }
166
167 #[test]
168 fn ssh_url_form() {
169 assert_eq!(
170 canonicalize_git_url("ssh://git@github.com/foo/bar"),
171 "github.com/foo/bar"
172 );
173 }
174
175 #[test]
176 fn git_protocol_form() {
177 assert_eq!(
178 canonicalize_git_url("git://github.com/foo/bar"),
179 "github.com/foo/bar"
180 );
181 }
182
183 #[test]
184 fn scp_form_git_at() {
185 assert_eq!(
186 canonicalize_git_url("git@github.com:foo/bar.git"),
187 "github.com/foo/bar"
188 );
189 }
190
191 #[test]
192 fn bare_domain_form() {
193 assert_eq!(
194 canonicalize_git_url("github.com/meridian-flow/meridian-base"),
195 "github.com/meridian-flow/meridian-base"
196 );
197 }
198
199 #[test]
202 fn ssh_and_https_converge() {
203 let https = canonicalize_git_url("https://github.com/foo/bar");
204 let ssh = canonicalize_git_url("git@github.com:foo/bar.git");
205 let ssh_url = canonicalize_git_url("ssh://git@github.com/foo/bar");
206 assert_eq!(https, ssh);
207 assert_eq!(https, ssh_url);
208 }
209
210 #[test]
213 fn strips_git_suffix_https() {
214 assert_eq!(
215 canonicalize_git_url("https://github.com/foo/bar.git"),
216 "github.com/foo/bar"
217 );
218 }
219
220 #[test]
221 fn strips_git_suffix_bare() {
222 assert_eq!(
223 canonicalize_git_url("github.com/foo/bar.git"),
224 "github.com/foo/bar"
225 );
226 }
227
228 #[test]
231 fn strips_trailing_slash() {
232 assert_eq!(
233 canonicalize_git_url("https://github.com/foo/bar/"),
234 "github.com/foo/bar"
235 );
236 }
237
238 #[test]
241 fn lowercases_host_only() {
242 assert_eq!(
243 canonicalize_git_url("GITHUB.COM/Foo/Bar"),
244 "github.com/Foo/Bar"
245 );
246 }
247
248 #[test]
249 fn lowercases_host_in_https_url() {
250 assert_eq!(
251 canonicalize_git_url("https://GITHUB.COM/Foo/Bar"),
252 "github.com/Foo/Bar"
253 );
254 }
255
256 #[test]
259 fn strips_userinfo_user_password() {
260 assert_eq!(
261 canonicalize_git_url("https://user:pass@github.com/foo/bar"),
262 "github.com/foo/bar"
263 );
264 }
265
266 #[test]
267 fn strips_userinfo_bare_user() {
268 assert_eq!(
269 canonicalize_git_url("https://user@github.com/foo/bar"),
270 "github.com/foo/bar"
271 );
272 }
273
274 #[test]
277 fn preserves_explicit_port_in_url_form() {
278 assert_eq!(
280 canonicalize_git_url("git://gitlab.localtest.me:19424/group/pkg.git"),
281 "gitlab.localtest.me:19424/group/pkg"
282 );
283 }
284
285 #[test]
286 fn preserves_explicit_port_in_https() {
287 assert_eq!(
288 canonicalize_git_url("https://git.example.com:8443/org/repo"),
289 "git.example.com:8443/org/repo"
290 );
291 }
292
293 #[test]
296 fn scp_colon_converted_to_slash() {
297 assert_eq!(
298 canonicalize_git_url("git@github.com:org/repo.git"),
299 "github.com/org/repo"
300 );
301 }
302
303 #[test]
306 fn scp_digit_leading_path_segment() {
307 assert_eq!(
309 canonicalize_git_url("git@example.com:123team/repo.git"),
310 "example.com/123team/repo"
311 );
312 }
313
314 #[test]
315 fn scp_digit_leading_path_segment_no_git_suffix() {
316 assert_eq!(
317 canonicalize_git_url("git@example.com:9front/repo"),
318 "example.com/9front/repo"
319 );
320 }
321
322 #[test]
325 fn port_only_digits_is_preserved_https() {
326 assert_eq!(
327 canonicalize_git_url("https://gitlab.com:8443/org/repo"),
328 "gitlab.com:8443/org/repo"
329 );
330 }
331
332 #[test]
333 fn port_only_digits_is_preserved_git_protocol() {
334 assert_eq!(
335 canonicalize_git_url("git://custom.host:19424/group/pkg.git"),
336 "custom.host:19424/group/pkg"
337 );
338 }
339
340 #[test]
343 fn subgroup_path_preserved() {
344 assert_eq!(
345 canonicalize_git_url("https://gitlab.com/group/subgroup/repo"),
346 "gitlab.com/group/subgroup/repo"
347 );
348 }
349
350 #[test]
351 fn subgroup_path_ssh_preserved() {
352 assert_eq!(
353 canonicalize_git_url("git@gitlab.com:group/subgroup/repo.git"),
354 "gitlab.com/group/subgroup/repo"
355 );
356 }
357
358 #[test]
361 fn bare_host_no_path_lowercased() {
362 assert_eq!(canonicalize_git_url("GITHUB.COM"), "github.com");
363 }
364
365 #[test]
368 fn idempotent_on_already_canonical_form() {
369 let canonical = "github.com/foo/bar";
370 assert_eq!(canonicalize_git_url(canonical), canonical);
371 }
372
373 #[test]
376 fn default_scheme_table() {
377 let unchanged = [
378 "git@host:/srv/repo.git",
379 "git@host.example:org/repo.git",
380 "git@host:org/repo",
381 "github.com:org/repo",
382 "example.com:123team/repo",
383 "host:~user/repo",
384 "~user@host:repo",
385 "./a:b",
386 "~/x:y",
387 "[::1]:8080/org/repo.git",
388 "localhost:8080/repo",
389 "C:/x/repo.d",
390 "C:\\x\\repo",
391 "D:repo.git/x",
392 "./rel/x.git",
393 "../up/x.git",
394 "/abs/x.y/repo",
395 "~/x.y/repo",
396 "file:/x.y/r",
397 "file:///x.y/r",
398 "ssh://git@github.com/org/repo",
399 "https://github.com/org/repo",
400 "org/repo",
401 ];
402 for url in unchanged {
403 assert_eq!(with_default_scheme(url), url, "{url}");
404 }
405 assert_eq!(
406 with_default_scheme("github.com/org/repo"),
407 "https://github.com/org/repo"
408 );
409 assert_eq!(
410 with_default_scheme("example.com:8080/org/repo"),
411 "https://example.com:8080/org/repo"
412 );
413 }
414
415 #[test]
416 fn ssh_shorthand_table() {
417 for url in [
418 "git@host:/srv/repo.git",
419 "git@host:org/repo",
420 "git@host:8080/repo",
421 "github.com:org/repo",
422 "example.com:123team/repo",
423 "host:/srv/repo.git",
424 "host:~user/repo",
425 "~user@host:repo",
426 ] {
427 assert!(is_ssh_shorthand(url), "{url}");
428 }
429 for url in [
430 "example.com:8080/org/repo",
431 "git@host:",
432 "@host:org/repo",
433 "ssh://git@host/org/repo",
434 "/srv/repo:x",
435 "./a:b",
436 "~/x:y",
437 "C:/x",
438 "\\\\?\\C:\\x",
439 "file:/x",
440 "org/repo",
441 ] {
442 assert!(!is_ssh_shorthand(url), "{url}");
443 }
444 }
445}