Skip to main content

git_proc/
url.rs

1use std::borrow::Cow;
2use std::path::{Path, PathBuf};
3
4/// A validated git repository URL.
5#[derive(Clone, Debug, Eq, PartialEq)]
6pub enum GitUrl {
7    /// SSH URL: `git@host:path` or `ssh://user@host/path`
8    Ssh(SshUrl),
9    /// HTTPS URL: `https://host/path`
10    Https(HttpsUrl),
11    /// Git protocol URL: `git://host/path`
12    Git(GitProtocolUrl),
13    /// Local file path: `/path/to/repo` or `file:///path/to/repo`
14    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        // Try SCP-style SSH first (not a valid URL, so must be checked before URL parsing)
50        if let Some(url) = SshUrl::from_scp(input) {
51            return Ok(Self::Ssh(url));
52        }
53
54        // Try parsing as a URL
55        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        // Try absolute path
66        if let Ok(url) = input.parse::<PathUrl>() {
67            return Ok(Self::Path(url));
68        }
69
70        Err(GitUrlError::InvalidFormat)
71    }
72}
73
74/// A git remote reference: either a named remote or a URL.
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub enum Remote {
77    /// A named remote (e.g., `origin`, `upstream`).
78    Name(RemoteName),
79    /// A repository URL.
80    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        // Try parsing as URL first
114        if let Ok(url) = input.parse::<GitUrl>() {
115            return Ok(Self::Url(url));
116        }
117
118        // Fall back to remote name
119        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/// A named git remote (e.g., `origin`, `upstream`).
136#[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        // Using index loop because iterators are not const-compatible.
148        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    /// Create a `RemoteName` from a static string, panicking if invalid.
159    ///
160    /// Use this for known-valid static remote names like `"origin"`.
161    ///
162    /// # Panics
163    ///
164    /// Panics if the input is empty or contains whitespace.
165    #[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/// SSH URL: `git@host:path` (SCP-style) or `ssh://user@host/path`
207#[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    /// Parse SCP-style SSH URL: `user@host:path`
234    ///
235    /// Path must not start with `/` to distinguish from URL-style.
236    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/// HTTPS URL: `https://host/path`
278#[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/// Git protocol URL: `git://host/path`
317#[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/// Local file path URL: `/path/to/repo` or `file:///path/to/repo`
358#[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        // git@host:/path is not valid SCP (path starts with /) and not a valid URL
538        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}