Skip to main content

git_proc/
repository.rs

1use std::borrow::Cow;
2use std::path::{Path, PathBuf};
3
4/// A validated git repository address.
5#[derive(Clone, Debug, Eq, PartialEq)]
6pub enum Address {
7    /// SSH address: `git@host:path` (SCP-style) or `ssh://user@host/path`
8    Ssh(SshAddress),
9    /// HTTPS URL: `https://host/path`
10    Https(HttpsUrl),
11    /// Git protocol URL: `git://host/path`
12    Git(GitUrl),
13    /// Local file path: `/path/to/repo` or `file:///path/to/repo`
14    Path(PathAddress),
15}
16
17impl Address {
18    #[must_use]
19    pub fn as_str(&self) -> &str {
20        match self {
21            Self::Ssh(address) => address.as_str(),
22            Self::Https(address) => address.as_str(),
23            Self::Git(address) => address.as_str(),
24            Self::Path(address) => address.as_str(),
25        }
26    }
27}
28
29impl std::fmt::Display for Address {
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 Address {
36    fn as_ref(&self) -> &std::ffi::OsStr {
37        self.as_str().as_ref()
38    }
39}
40
41impl std::str::FromStr for Address {
42    type Err = AddressError;
43
44    fn from_str(input: &str) -> Result<Self, Self::Err> {
45        if input.is_empty() {
46            return Err(AddressError::Empty);
47        }
48
49        // Try SCP-style SSH first (not a valid URL, so must be checked before URL parsing)
50        if let Some(address) = SshAddress::from_scp(input) {
51            return Ok(Self::Ssh(address));
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(SshAddress::from_parsed(input, parsed)?)),
59                "git" => return Ok(Self::Git(GitUrl::from_parsed(input, parsed)?)),
60                "file" => return Ok(Self::Path(PathAddress::from_parsed(input, parsed)?)),
61                _ => {}
62            }
63        }
64
65        // Try absolute path
66        if let Ok(address) = input.parse::<PathAddress>() {
67            return Ok(Self::Path(address));
68        }
69
70        Err(AddressError::InvalidFormat)
71    }
72}
73
74/// A git remote reference: either a named remote or an address.
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub enum Remote {
77    /// A named remote (e.g., `origin`, `upstream`).
78    Name(RemoteName),
79    /// A repository address.
80    RepositoryAddress(Address),
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::RepositoryAddress(address) => address.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 address first
114        if let Ok(address) = input.parse::<Address>() {
115            return Ok(Self::RepositoryAddress(address));
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<Address> for Remote {
130    fn from(address: Address) -> Self {
131        Self::RepositoryAddress(address)
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 address: `git@host:path` (SCP-style) or `ssh://user@host/path`
207#[derive(Clone, Debug, Eq, PartialEq)]
208pub struct SshAddress {
209    raw: String,
210    user: String,
211    host: String,
212    path: String,
213}
214
215impl SshAddress {
216    fn from_parsed(raw: &str, parsed: url::Url) -> Result<Self, AddressError> {
217        let user = parsed.username();
218        let host = parsed.host_str().ok_or(AddressError::InvalidSshAddress)?;
219        let path = parsed.path();
220
221        if user.is_empty() || host.is_empty() {
222            return Err(AddressError::InvalidSshAddress);
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 address: `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, AddressError> {
287        let host = parsed.host_str().ok_or(AddressError::InvalidHttpsUrl)?;
288
289        if host.is_empty() {
290            return Err(AddressError::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 GitUrl {
319    raw: String,
320    host: String,
321    path: String,
322}
323
324impl GitUrl {
325    fn from_parsed(raw: &str, parsed: url::Url) -> Result<Self, AddressError> {
326        let host = parsed.host_str().ok_or(AddressError::InvalidGitUrl)?;
327
328        if host.is_empty() {
329            return Err(AddressError::InvalidGitUrl);
330        }
331
332        Ok(Self {
333            raw: raw.to_string(),
334            host: host.to_string(),
335            path: parsed.path().to_string(),
336        })
337    }
338
339    #[must_use]
340    pub fn as_str(&self) -> &str {
341        &self.raw
342    }
343
344    #[must_use]
345    pub fn host(&self) -> &str {
346        &self.host
347    }
348
349    #[must_use]
350    pub fn path(&self) -> &str {
351        &self.path
352    }
353}
354
355/// Local file path address: `/path/to/repo` or `file:///path/to/repo`
356#[derive(Clone, Debug, Eq, PartialEq)]
357pub struct PathAddress {
358    raw: String,
359    path: PathBuf,
360}
361
362impl PathAddress {
363    fn from_parsed(raw: &str, parsed: url::Url) -> Result<Self, AddressError> {
364        let path = parsed
365            .to_file_path()
366            .map_err(|()| AddressError::InvalidPathAddress)?;
367
368        Ok(Self {
369            raw: raw.to_string(),
370            path,
371        })
372    }
373
374    #[must_use]
375    pub fn as_str(&self) -> &str {
376        &self.raw
377    }
378
379    #[must_use]
380    pub fn path(&self) -> &Path {
381        &self.path
382    }
383}
384
385impl std::str::FromStr for PathAddress {
386    type Err = AddressError;
387
388    fn from_str(input: &str) -> Result<Self, Self::Err> {
389        let path = PathBuf::from(input);
390
391        if path.is_absolute() {
392            return Ok(Self {
393                raw: input.to_string(),
394                path,
395            });
396        }
397
398        Err(AddressError::InvalidPathAddress)
399    }
400}
401
402#[derive(Debug, thiserror::Error)]
403pub enum AddressError {
404    #[error("Repository address cannot be empty")]
405    Empty,
406    #[error("Invalid repository address format")]
407    InvalidFormat,
408    #[error("Invalid SSH address format (expected user@host:path or ssh://user@host/path)")]
409    InvalidSshAddress,
410    #[error("Invalid HTTPS URL format (expected https://host/path)")]
411    InvalidHttpsUrl,
412    #[error("Invalid git:// URL format (expected git://host/path)")]
413    InvalidGitUrl,
414    #[error("Invalid path address format (expected absolute path or file:// URL)")]
415    InvalidPathAddress,
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_ssh_scp_style() {
424        let address: Address = "git@github.com:user/repo.git".parse().unwrap();
425        assert!(matches!(address, Address::Ssh(_)));
426        if let Address::Ssh(ssh) = address {
427            assert_eq!(ssh.user(), "git");
428            assert_eq!(ssh.host(), "github.com");
429            assert_eq!(ssh.path(), "user/repo.git");
430        }
431    }
432
433    #[test]
434    fn test_ssh_url_style() {
435        let address: Address = "ssh://git@github.com/user/repo.git".parse().unwrap();
436        assert!(matches!(address, Address::Ssh(_)));
437        if let Address::Ssh(ssh) = address {
438            assert_eq!(ssh.user(), "git");
439            assert_eq!(ssh.host(), "github.com");
440            assert_eq!(ssh.path(), "/user/repo.git");
441        }
442    }
443
444    #[test]
445    fn test_https() {
446        let address: Address = "https://github.com/user/repo.git".parse().unwrap();
447        assert!(matches!(address, Address::Https(_)));
448        if let Address::Https(https) = address {
449            assert_eq!(https.host(), "github.com");
450            assert_eq!(https.path(), "/user/repo.git");
451        }
452    }
453
454    #[test]
455    fn test_git_protocol() {
456        let address: Address = "git://github.com/user/repo.git".parse().unwrap();
457        assert!(matches!(address, Address::Git(_)));
458        if let Address::Git(git) = address {
459            assert_eq!(git.host(), "github.com");
460            assert_eq!(git.path(), "/user/repo.git");
461        }
462    }
463
464    #[test]
465    fn test_file_url() {
466        let address: Address = "file:///home/user/repo".parse().unwrap();
467        assert!(matches!(address, Address::Path(_)));
468        if let Address::Path(path) = address {
469            assert_eq!(path.path(), Path::new("/home/user/repo"));
470        }
471    }
472
473    #[test]
474    fn test_absolute_path() {
475        let address: Address = "/home/user/repo".parse().unwrap();
476        assert!(matches!(address, Address::Path(_)));
477        if let Address::Path(path) = address {
478            assert_eq!(path.path(), Path::new("/home/user/repo"));
479        }
480    }
481
482    #[test]
483    fn test_empty() {
484        assert!(matches!("".parse::<Address>(), Err(AddressError::Empty)));
485    }
486
487    #[test]
488    fn test_invalid() {
489        assert!(matches!(
490            "not-a-valid-url".parse::<Address>(),
491            Err(AddressError::InvalidFormat)
492        ));
493    }
494
495    #[test]
496    fn test_display() {
497        let address: Address = "git@github.com:user/repo.git".parse().unwrap();
498        assert_eq!(address.to_string(), "git@github.com:user/repo.git");
499        assert_eq!(address.as_str(), "git@github.com:user/repo.git");
500    }
501
502    #[test]
503    fn test_as_ref_os_str() {
504        let address: Address = "git@github.com:user/repo.git".parse().unwrap();
505        let os_str: &std::ffi::OsStr = address.as_ref();
506        assert_eq!(os_str, "git@github.com:user/repo.git");
507    }
508
509    #[test]
510    fn test_scp_empty_user() {
511        assert!(matches!(
512            "@github.com:path".parse::<Address>(),
513            Err(AddressError::InvalidFormat)
514        ));
515    }
516
517    #[test]
518    fn test_scp_empty_host() {
519        assert!(matches!(
520            "git@:path".parse::<Address>(),
521            Err(AddressError::InvalidFormat)
522        ));
523    }
524
525    #[test]
526    fn test_scp_empty_path() {
527        assert!(matches!(
528            "git@github.com:".parse::<Address>(),
529            Err(AddressError::InvalidFormat)
530        ));
531    }
532
533    #[test]
534    fn test_scp_path_with_leading_slash_rejected() {
535        // git@host:/path is not valid SCP (path starts with /) and not a valid URL
536        assert!(matches!(
537            "git@github.com:/user/repo".parse::<Address>(),
538            Err(AddressError::InvalidFormat)
539        ));
540    }
541
542    #[test]
543    fn test_ssh_url_missing_user() {
544        assert!(matches!(
545            "ssh://github.com/user/repo.git".parse::<Address>(),
546            Err(AddressError::InvalidSshAddress)
547        ));
548    }
549
550    #[test]
551    fn test_relative_path() {
552        assert!(matches!(
553            "./relative/path".parse::<Address>(),
554            Err(AddressError::InvalidFormat)
555        ));
556    }
557
558    #[test]
559    fn test_unknown_scheme() {
560        assert!(matches!(
561            "ftp://example.com/repo".parse::<Address>(),
562            Err(AddressError::InvalidFormat)
563        ));
564    }
565
566    #[test]
567    fn test_remote_name() {
568        let remote: Remote = "origin".parse().unwrap();
569        assert!(matches!(remote, Remote::Name(_)));
570        assert_eq!(remote.as_str(), "origin");
571    }
572
573    #[test]
574    fn test_remote_repository_address() {
575        let remote: Remote = "git@github.com:user/repo.git".parse().unwrap();
576        assert!(matches!(remote, Remote::RepositoryAddress(_)));
577        assert_eq!(remote.as_str(), "git@github.com:user/repo.git");
578    }
579
580    #[test]
581    fn test_remote_https_url() {
582        let remote: Remote = "https://github.com/user/repo.git".parse().unwrap();
583        assert!(matches!(remote, Remote::RepositoryAddress(_)));
584    }
585
586    #[test]
587    fn test_remote_empty() {
588        assert!(matches!("".parse::<Remote>(), Err(RemoteError::Empty)));
589    }
590
591    #[test]
592    fn test_remote_name_with_whitespace() {
593        assert!(matches!(
594            "origin upstream".parse::<Remote>(),
595            Err(RemoteError::InvalidRemoteName)
596        ));
597    }
598
599    #[test]
600    fn test_remote_name_display() {
601        let name: RemoteName = "origin".parse().unwrap();
602        assert_eq!(name.to_string(), "origin");
603    }
604
605    #[test]
606    fn test_remote_from_remote_name() {
607        let name: RemoteName = "upstream".parse().unwrap();
608        let remote: Remote = name.into();
609        assert!(matches!(remote, Remote::Name(_)));
610    }
611
612    #[test]
613    fn test_remote_from_address() {
614        let address: Address = "git@github.com:user/repo.git".parse().unwrap();
615        let remote: Remote = address.into();
616        assert!(matches!(remote, Remote::RepositoryAddress(_)));
617    }
618}