1use std::borrow::Cow;
2use std::path::{Path, PathBuf};
3
4#[derive(Clone, Debug, Eq, PartialEq)]
6pub enum GitUrl {
7 Ssh(SshUrl),
9 Https(HttpsUrl),
11 Git(GitProtocolUrl),
13 Path(PathUrl),
15}
16
17impl GitUrl {
18 #[must_use]
19 pub fn as_str(&self) -> &str {
20 match self {
21 Self::Ssh(url) => url.as_str(),
22 Self::Https(url) => url.as_str(),
23 Self::Git(url) => url.as_str(),
24 Self::Path(url) => url.as_str(),
25 }
26 }
27}
28
29impl std::fmt::Display for GitUrl {
30 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 write!(formatter, "{}", self.as_str())
32 }
33}
34
35impl AsRef<std::ffi::OsStr> for GitUrl {
36 fn as_ref(&self) -> &std::ffi::OsStr {
37 self.as_str().as_ref()
38 }
39}
40
41impl std::str::FromStr for GitUrl {
42 type Err = GitUrlError;
43
44 fn from_str(input: &str) -> Result<Self, Self::Err> {
45 if input.is_empty() {
46 return Err(GitUrlError::Empty);
47 }
48
49 if let Some(url) = SshUrl::from_scp(input) {
51 return Ok(Self::Ssh(url));
52 }
53
54 if let Ok(parsed) = url::Url::parse(input) {
56 match parsed.scheme() {
57 "https" => return Ok(Self::Https(HttpsUrl::from_parsed(input, parsed)?)),
58 "ssh" => return Ok(Self::Ssh(SshUrl::from_parsed(input, parsed)?)),
59 "git" => return Ok(Self::Git(GitProtocolUrl::from_parsed(input, parsed)?)),
60 "file" => return Ok(Self::Path(PathUrl::from_parsed(input, parsed)?)),
61 _ => {}
62 }
63 }
64
65 if let Ok(url) = input.parse::<PathUrl>() {
67 return Ok(Self::Path(url));
68 }
69
70 Err(GitUrlError::InvalidFormat)
71 }
72}
73
74#[derive(Clone, Debug, Eq, PartialEq)]
76pub enum Remote {
77 Name(RemoteName),
79 Url(GitUrl),
81}
82
83impl Remote {
84 #[must_use]
85 pub fn as_str(&self) -> &str {
86 match self {
87 Self::Name(name) => name.as_str(),
88 Self::Url(url) => url.as_str(),
89 }
90 }
91}
92
93impl std::fmt::Display for Remote {
94 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 write!(formatter, "{}", self.as_str())
96 }
97}
98
99impl AsRef<std::ffi::OsStr> for Remote {
100 fn as_ref(&self) -> &std::ffi::OsStr {
101 self.as_str().as_ref()
102 }
103}
104
105impl std::str::FromStr for Remote {
106 type Err = RemoteError;
107
108 fn from_str(input: &str) -> Result<Self, Self::Err> {
109 if input.is_empty() {
110 return Err(RemoteError::Empty);
111 }
112
113 if let Ok(url) = input.parse::<GitUrl>() {
115 return Ok(Self::Url(url));
116 }
117
118 input.parse::<RemoteName>().map(Self::Name)
120 }
121}
122
123impl From<RemoteName> for Remote {
124 fn from(name: RemoteName) -> Self {
125 Self::Name(name)
126 }
127}
128
129impl From<GitUrl> for Remote {
130 fn from(url: GitUrl) -> Self {
131 Self::Url(url)
132 }
133}
134
135#[derive(Clone, Debug, Eq, PartialEq)]
137pub struct RemoteName(Cow<'static, str>);
138
139impl RemoteName {
140 const fn validate(input: &str) -> Result<(), RemoteError> {
141 if input.is_empty() {
142 return Err(RemoteError::Empty);
143 }
144
145 let bytes = input.as_bytes();
146 let mut index = 0;
147 while index < bytes.len() {
149 if bytes[index].is_ascii_whitespace() {
150 return Err(RemoteError::InvalidRemoteName);
151 }
152 index += 1;
153 }
154
155 Ok(())
156 }
157
158 #[must_use]
166 pub const fn from_static_or_panic(input: &'static str) -> Self {
167 assert!(Self::validate(input).is_ok(), "invalid remote name");
168 Self(Cow::Borrowed(input))
169 }
170
171 #[must_use]
172 pub fn as_str(&self) -> &str {
173 &self.0
174 }
175}
176
177impl std::fmt::Display for RemoteName {
178 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 write!(formatter, "{}", self.0)
180 }
181}
182
183impl AsRef<std::ffi::OsStr> for RemoteName {
184 fn as_ref(&self) -> &std::ffi::OsStr {
185 self.as_str().as_ref()
186 }
187}
188
189impl std::str::FromStr for RemoteName {
190 type Err = RemoteError;
191
192 fn from_str(input: &str) -> Result<Self, Self::Err> {
193 Self::validate(input)?;
194 Ok(Self(Cow::Owned(input.to_string())))
195 }
196}
197
198#[derive(Debug, thiserror::Error)]
199pub enum RemoteError {
200 #[error("Remote cannot be empty")]
201 Empty,
202 #[error("Invalid remote name")]
203 InvalidRemoteName,
204}
205
206#[derive(Clone, Debug, Eq, PartialEq)]
208pub struct SshUrl {
209 raw: String,
210 user: String,
211 host: String,
212 path: String,
213}
214
215impl SshUrl {
216 fn from_parsed(raw: &str, parsed: url::Url) -> Result<Self, GitUrlError> {
217 let user = parsed.username();
218 let host = parsed.host_str().ok_or(GitUrlError::InvalidSshUrl)?;
219 let path = parsed.path();
220
221 if user.is_empty() || host.is_empty() {
222 return Err(GitUrlError::InvalidSshUrl);
223 }
224
225 Ok(Self {
226 raw: raw.to_string(),
227 user: user.to_string(),
228 host: host.to_string(),
229 path: path.to_string(),
230 })
231 }
232
233 fn from_scp(input: &str) -> Option<Self> {
237 let (user_host, path) = input.split_once(':')?;
238 let (user, host) = user_host.split_once('@')?;
239
240 if path.starts_with('/') || path.starts_with("//") {
241 return None;
242 }
243
244 if user.is_empty() || host.is_empty() || path.is_empty() {
245 return None;
246 }
247
248 Some(Self {
249 raw: input.to_string(),
250 user: user.to_string(),
251 host: host.to_string(),
252 path: path.to_string(),
253 })
254 }
255
256 #[must_use]
257 pub fn as_str(&self) -> &str {
258 &self.raw
259 }
260
261 #[must_use]
262 pub fn user(&self) -> &str {
263 &self.user
264 }
265
266 #[must_use]
267 pub fn host(&self) -> &str {
268 &self.host
269 }
270
271 #[must_use]
272 pub fn path(&self) -> &str {
273 &self.path
274 }
275}
276
277#[derive(Clone, Debug, Eq, PartialEq)]
279pub struct HttpsUrl {
280 raw: String,
281 host: String,
282 path: String,
283}
284
285impl HttpsUrl {
286 fn from_parsed(raw: &str, parsed: url::Url) -> Result<Self, GitUrlError> {
287 let host = parsed.host_str().ok_or(GitUrlError::InvalidHttpsUrl)?;
288
289 if host.is_empty() {
290 return Err(GitUrlError::InvalidHttpsUrl);
291 }
292
293 Ok(Self {
294 raw: raw.to_string(),
295 host: host.to_string(),
296 path: parsed.path().to_string(),
297 })
298 }
299
300 #[must_use]
301 pub fn as_str(&self) -> &str {
302 &self.raw
303 }
304
305 #[must_use]
306 pub fn host(&self) -> &str {
307 &self.host
308 }
309
310 #[must_use]
311 pub fn path(&self) -> &str {
312 &self.path
313 }
314}
315
316#[derive(Clone, Debug, Eq, PartialEq)]
318pub struct GitProtocolUrl {
319 raw: String,
320 host: String,
321 path: String,
322}
323
324impl GitProtocolUrl {
325 fn from_parsed(raw: &str, parsed: url::Url) -> Result<Self, GitUrlError> {
326 let host = parsed
327 .host_str()
328 .ok_or(GitUrlError::InvalidGitProtocolUrl)?;
329
330 if host.is_empty() {
331 return Err(GitUrlError::InvalidGitProtocolUrl);
332 }
333
334 Ok(Self {
335 raw: raw.to_string(),
336 host: host.to_string(),
337 path: parsed.path().to_string(),
338 })
339 }
340
341 #[must_use]
342 pub fn as_str(&self) -> &str {
343 &self.raw
344 }
345
346 #[must_use]
347 pub fn host(&self) -> &str {
348 &self.host
349 }
350
351 #[must_use]
352 pub fn path(&self) -> &str {
353 &self.path
354 }
355}
356
357#[derive(Clone, Debug, Eq, PartialEq)]
359pub struct PathUrl {
360 raw: String,
361 path: PathBuf,
362}
363
364impl PathUrl {
365 fn from_parsed(raw: &str, parsed: url::Url) -> Result<Self, GitUrlError> {
366 let path = parsed
367 .to_file_path()
368 .map_err(|()| GitUrlError::InvalidPathUrl)?;
369
370 Ok(Self {
371 raw: raw.to_string(),
372 path,
373 })
374 }
375
376 #[must_use]
377 pub fn as_str(&self) -> &str {
378 &self.raw
379 }
380
381 #[must_use]
382 pub fn path(&self) -> &Path {
383 &self.path
384 }
385}
386
387impl std::str::FromStr for PathUrl {
388 type Err = GitUrlError;
389
390 fn from_str(input: &str) -> Result<Self, Self::Err> {
391 let path = PathBuf::from(input);
392
393 if path.is_absolute() {
394 return Ok(Self {
395 raw: input.to_string(),
396 path,
397 });
398 }
399
400 Err(GitUrlError::InvalidPathUrl)
401 }
402}
403
404#[derive(Debug, thiserror::Error)]
405pub enum GitUrlError {
406 #[error("Git URL cannot be empty")]
407 Empty,
408 #[error("Invalid git URL format")]
409 InvalidFormat,
410 #[error("Invalid SSH URL format (expected user@host:path or ssh://user@host/path)")]
411 InvalidSshUrl,
412 #[error("Invalid HTTPS URL format (expected https://host/path)")]
413 InvalidHttpsUrl,
414 #[error("Invalid git protocol URL format (expected git://host/path)")]
415 InvalidGitProtocolUrl,
416 #[error("Invalid path URL format (expected absolute path or file:// URL)")]
417 InvalidPathUrl,
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn test_ssh_scp_style() {
426 let url: GitUrl = "git@github.com:user/repo.git".parse().unwrap();
427 assert!(matches!(url, GitUrl::Ssh(_)));
428 if let GitUrl::Ssh(ssh) = url {
429 assert_eq!(ssh.user(), "git");
430 assert_eq!(ssh.host(), "github.com");
431 assert_eq!(ssh.path(), "user/repo.git");
432 }
433 }
434
435 #[test]
436 fn test_ssh_url_style() {
437 let url: GitUrl = "ssh://git@github.com/user/repo.git".parse().unwrap();
438 assert!(matches!(url, GitUrl::Ssh(_)));
439 if let GitUrl::Ssh(ssh) = url {
440 assert_eq!(ssh.user(), "git");
441 assert_eq!(ssh.host(), "github.com");
442 assert_eq!(ssh.path(), "/user/repo.git");
443 }
444 }
445
446 #[test]
447 fn test_https() {
448 let url: GitUrl = "https://github.com/user/repo.git".parse().unwrap();
449 assert!(matches!(url, GitUrl::Https(_)));
450 if let GitUrl::Https(https) = url {
451 assert_eq!(https.host(), "github.com");
452 assert_eq!(https.path(), "/user/repo.git");
453 }
454 }
455
456 #[test]
457 fn test_git_protocol() {
458 let url: GitUrl = "git://github.com/user/repo.git".parse().unwrap();
459 assert!(matches!(url, GitUrl::Git(_)));
460 if let GitUrl::Git(git) = url {
461 assert_eq!(git.host(), "github.com");
462 assert_eq!(git.path(), "/user/repo.git");
463 }
464 }
465
466 #[test]
467 fn test_file_url() {
468 let url: GitUrl = "file:///home/user/repo".parse().unwrap();
469 assert!(matches!(url, GitUrl::Path(_)));
470 if let GitUrl::Path(path) = url {
471 assert_eq!(path.path(), Path::new("/home/user/repo"));
472 }
473 }
474
475 #[test]
476 fn test_absolute_path() {
477 let url: GitUrl = "/home/user/repo".parse().unwrap();
478 assert!(matches!(url, GitUrl::Path(_)));
479 if let GitUrl::Path(path) = url {
480 assert_eq!(path.path(), Path::new("/home/user/repo"));
481 }
482 }
483
484 #[test]
485 fn test_empty() {
486 assert!(matches!("".parse::<GitUrl>(), Err(GitUrlError::Empty)));
487 }
488
489 #[test]
490 fn test_invalid() {
491 assert!(matches!(
492 "not-a-valid-url".parse::<GitUrl>(),
493 Err(GitUrlError::InvalidFormat)
494 ));
495 }
496
497 #[test]
498 fn test_display() {
499 let url: GitUrl = "git@github.com:user/repo.git".parse().unwrap();
500 assert_eq!(url.to_string(), "git@github.com:user/repo.git");
501 assert_eq!(url.as_str(), "git@github.com:user/repo.git");
502 }
503
504 #[test]
505 fn test_as_ref_os_str() {
506 let url: GitUrl = "git@github.com:user/repo.git".parse().unwrap();
507 let os_str: &std::ffi::OsStr = url.as_ref();
508 assert_eq!(os_str, "git@github.com:user/repo.git");
509 }
510
511 #[test]
512 fn test_scp_empty_user() {
513 assert!(matches!(
514 "@github.com:path".parse::<GitUrl>(),
515 Err(GitUrlError::InvalidFormat)
516 ));
517 }
518
519 #[test]
520 fn test_scp_empty_host() {
521 assert!(matches!(
522 "git@:path".parse::<GitUrl>(),
523 Err(GitUrlError::InvalidFormat)
524 ));
525 }
526
527 #[test]
528 fn test_scp_empty_path() {
529 assert!(matches!(
530 "git@github.com:".parse::<GitUrl>(),
531 Err(GitUrlError::InvalidFormat)
532 ));
533 }
534
535 #[test]
536 fn test_scp_path_with_leading_slash_rejected() {
537 assert!(matches!(
539 "git@github.com:/user/repo".parse::<GitUrl>(),
540 Err(GitUrlError::InvalidFormat)
541 ));
542 }
543
544 #[test]
545 fn test_ssh_url_missing_user() {
546 assert!(matches!(
547 "ssh://github.com/user/repo.git".parse::<GitUrl>(),
548 Err(GitUrlError::InvalidSshUrl)
549 ));
550 }
551
552 #[test]
553 fn test_relative_path() {
554 assert!(matches!(
555 "./relative/path".parse::<GitUrl>(),
556 Err(GitUrlError::InvalidFormat)
557 ));
558 }
559
560 #[test]
561 fn test_unknown_scheme() {
562 assert!(matches!(
563 "ftp://example.com/repo".parse::<GitUrl>(),
564 Err(GitUrlError::InvalidFormat)
565 ));
566 }
567
568 #[test]
569 fn test_remote_name() {
570 let remote: Remote = "origin".parse().unwrap();
571 assert!(matches!(remote, Remote::Name(_)));
572 assert_eq!(remote.as_str(), "origin");
573 }
574
575 #[test]
576 fn test_remote_url() {
577 let remote: Remote = "git@github.com:user/repo.git".parse().unwrap();
578 assert!(matches!(remote, Remote::Url(_)));
579 assert_eq!(remote.as_str(), "git@github.com:user/repo.git");
580 }
581
582 #[test]
583 fn test_remote_https_url() {
584 let remote: Remote = "https://github.com/user/repo.git".parse().unwrap();
585 assert!(matches!(remote, Remote::Url(_)));
586 }
587
588 #[test]
589 fn test_remote_empty() {
590 assert!(matches!("".parse::<Remote>(), Err(RemoteError::Empty)));
591 }
592
593 #[test]
594 fn test_remote_name_with_whitespace() {
595 assert!(matches!(
596 "origin upstream".parse::<Remote>(),
597 Err(RemoteError::InvalidRemoteName)
598 ));
599 }
600
601 #[test]
602 fn test_remote_name_display() {
603 let name: RemoteName = "origin".parse().unwrap();
604 assert_eq!(name.to_string(), "origin");
605 }
606
607 #[test]
608 fn test_remote_from_remote_name() {
609 let name: RemoteName = "upstream".parse().unwrap();
610 let remote: Remote = name.into();
611 assert!(matches!(remote, Remote::Name(_)));
612 }
613
614 #[test]
615 fn test_remote_from_git_url() {
616 let url: GitUrl = "git@github.com:user/repo.git".parse().unwrap();
617 let remote: Remote = url.into();
618 assert!(matches!(remote, Remote::Url(_)));
619 }
620}