Skip to main content

git_proc/
repository.rs

1use crate::ref_format::{self, RefFormatError};
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 = RemoteNameError;
107
108    fn from_str(input: &str) -> Result<Self, Self::Err> {
109        // Try parsing as address first; fall back to remote name (which also
110        // produces the typed error for empty / invalid input).
111        if let Ok(address) = input.parse::<Address>() {
112            return Ok(Self::RepositoryAddress(address));
113        }
114
115        input.parse::<RemoteName>().map(Self::Name)
116    }
117}
118
119impl From<RemoteName> for Remote {
120    fn from(name: RemoteName) -> Self {
121        Self::Name(name)
122    }
123}
124
125impl From<Address> for Remote {
126    fn from(address: Address) -> Self {
127        Self::RepositoryAddress(address)
128    }
129}
130
131crate::cow_str_newtype! {
132    /// A named git remote (e.g., `origin`, `upstream`).
133    ///
134    /// Remote names are git refs (they live under `refs/remotes/<name>/...`)
135    /// and follow the same `check-ref-format` rules as branches and tags;
136    /// see [`crate::ref_format`].
137    pub struct RemoteName, RemoteNameError(RefFormatError), "invalid remote name"
138}
139
140impl RemoteName {
141    const fn validate(input: &str) -> Result<(), RemoteNameError> {
142        match ref_format::validate(input) {
143            Ok(()) => Ok(()),
144            Err(error) => Err(RemoteNameError(error)),
145        }
146    }
147}
148
149/// SSH address: `git@host:path` (SCP-style) or `ssh://user@host/path`
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct SshAddress {
152    raw: String,
153    user: String,
154    host: String,
155    path: String,
156}
157
158impl SshAddress {
159    fn from_parsed(raw: &str, parsed: url::Url) -> Result<Self, AddressError> {
160        let user = parsed.username();
161        let host = parsed.host_str().ok_or(AddressError::InvalidSshAddress)?;
162        let path = parsed.path();
163
164        if user.is_empty() || host.is_empty() {
165            return Err(AddressError::InvalidSshAddress);
166        }
167
168        Ok(Self {
169            raw: raw.to_string(),
170            user: user.to_string(),
171            host: host.to_string(),
172            path: path.to_string(),
173        })
174    }
175
176    /// Parse SCP-style SSH address: `user@host:path`
177    ///
178    /// Path must not start with `/` to distinguish from URL-style.
179    fn from_scp(input: &str) -> Option<Self> {
180        let (user_host, path) = input.split_once(':')?;
181        let (user, host) = user_host.split_once('@')?;
182
183        if path.starts_with('/') || path.starts_with("//") {
184            return None;
185        }
186
187        if user.is_empty() || host.is_empty() || path.is_empty() {
188            return None;
189        }
190
191        Some(Self {
192            raw: input.to_string(),
193            user: user.to_string(),
194            host: host.to_string(),
195            path: path.to_string(),
196        })
197    }
198
199    #[must_use]
200    pub fn as_str(&self) -> &str {
201        &self.raw
202    }
203
204    #[must_use]
205    pub fn user(&self) -> &str {
206        &self.user
207    }
208
209    #[must_use]
210    pub fn host(&self) -> &str {
211        &self.host
212    }
213
214    #[must_use]
215    pub fn path(&self) -> &str {
216        &self.path
217    }
218}
219
220/// HTTPS URL: `https://host/path`
221#[derive(Clone, Debug, Eq, PartialEq)]
222pub struct HttpsUrl {
223    raw: String,
224    host: String,
225    path: String,
226}
227
228impl HttpsUrl {
229    fn from_parsed(raw: &str, parsed: url::Url) -> Result<Self, AddressError> {
230        let host = parsed.host_str().ok_or(AddressError::InvalidHttpsUrl)?;
231
232        if host.is_empty() {
233            return Err(AddressError::InvalidHttpsUrl);
234        }
235
236        Ok(Self {
237            raw: raw.to_string(),
238            host: host.to_string(),
239            path: parsed.path().to_string(),
240        })
241    }
242
243    #[must_use]
244    pub fn as_str(&self) -> &str {
245        &self.raw
246    }
247
248    #[must_use]
249    pub fn host(&self) -> &str {
250        &self.host
251    }
252
253    #[must_use]
254    pub fn path(&self) -> &str {
255        &self.path
256    }
257}
258
259/// Git protocol URL: `git://host/path`
260#[derive(Clone, Debug, Eq, PartialEq)]
261pub struct GitUrl {
262    raw: String,
263    host: String,
264    path: String,
265}
266
267impl GitUrl {
268    fn from_parsed(raw: &str, parsed: url::Url) -> Result<Self, AddressError> {
269        let host = parsed.host_str().ok_or(AddressError::InvalidGitUrl)?;
270
271        if host.is_empty() {
272            return Err(AddressError::InvalidGitUrl);
273        }
274
275        Ok(Self {
276            raw: raw.to_string(),
277            host: host.to_string(),
278            path: parsed.path().to_string(),
279        })
280    }
281
282    #[must_use]
283    pub fn as_str(&self) -> &str {
284        &self.raw
285    }
286
287    #[must_use]
288    pub fn host(&self) -> &str {
289        &self.host
290    }
291
292    #[must_use]
293    pub fn path(&self) -> &str {
294        &self.path
295    }
296}
297
298/// Local file path address: `/path/to/repo` or `file:///path/to/repo`
299#[derive(Clone, Debug, Eq, PartialEq)]
300pub struct PathAddress {
301    raw: String,
302    path: PathBuf,
303}
304
305impl PathAddress {
306    fn from_parsed(raw: &str, parsed: url::Url) -> Result<Self, AddressError> {
307        let path = parsed
308            .to_file_path()
309            .map_err(|()| AddressError::InvalidPathAddress)?;
310
311        Ok(Self {
312            raw: raw.to_string(),
313            path,
314        })
315    }
316
317    #[must_use]
318    pub fn as_str(&self) -> &str {
319        &self.raw
320    }
321
322    #[must_use]
323    pub fn path(&self) -> &Path {
324        &self.path
325    }
326}
327
328impl std::str::FromStr for PathAddress {
329    type Err = AddressError;
330
331    fn from_str(input: &str) -> Result<Self, Self::Err> {
332        let path = PathBuf::from(input);
333
334        if path.is_absolute() {
335            return Ok(Self {
336                raw: input.to_string(),
337                path,
338            });
339        }
340
341        Err(AddressError::InvalidPathAddress)
342    }
343}
344
345#[derive(Debug, thiserror::Error)]
346pub enum AddressError {
347    #[error("Repository address cannot be empty")]
348    Empty,
349    #[error("Invalid repository address format")]
350    InvalidFormat,
351    #[error("Invalid SSH address format (expected user@host:path or ssh://user@host/path)")]
352    InvalidSshAddress,
353    #[error("Invalid HTTPS URL format (expected https://host/path)")]
354    InvalidHttpsUrl,
355    #[error("Invalid git:// URL format (expected git://host/path)")]
356    InvalidGitUrl,
357    #[error("Invalid path address format (expected absolute path or file:// URL)")]
358    InvalidPathAddress,
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_ssh_scp_style() {
367        let address: Address = "git@github.com:user/repo.git".parse().unwrap();
368        assert!(matches!(address, Address::Ssh(_)));
369        if let Address::Ssh(ssh) = address {
370            assert_eq!(ssh.user(), "git");
371            assert_eq!(ssh.host(), "github.com");
372            assert_eq!(ssh.path(), "user/repo.git");
373        }
374    }
375
376    #[test]
377    fn test_ssh_url_style() {
378        let address: Address = "ssh://git@github.com/user/repo.git".parse().unwrap();
379        assert!(matches!(address, Address::Ssh(_)));
380        if let Address::Ssh(ssh) = address {
381            assert_eq!(ssh.user(), "git");
382            assert_eq!(ssh.host(), "github.com");
383            assert_eq!(ssh.path(), "/user/repo.git");
384        }
385    }
386
387    #[test]
388    fn test_https() {
389        let address: Address = "https://github.com/user/repo.git".parse().unwrap();
390        assert!(matches!(address, Address::Https(_)));
391        if let Address::Https(https) = address {
392            assert_eq!(https.host(), "github.com");
393            assert_eq!(https.path(), "/user/repo.git");
394        }
395    }
396
397    #[test]
398    fn test_git_protocol() {
399        let address: Address = "git://github.com/user/repo.git".parse().unwrap();
400        assert!(matches!(address, Address::Git(_)));
401        if let Address::Git(git) = address {
402            assert_eq!(git.host(), "github.com");
403            assert_eq!(git.path(), "/user/repo.git");
404        }
405    }
406
407    #[test]
408    fn test_file_url() {
409        let address: Address = "file:///home/user/repo".parse().unwrap();
410        assert!(matches!(address, Address::Path(_)));
411        if let Address::Path(path) = address {
412            assert_eq!(path.path(), Path::new("/home/user/repo"));
413        }
414    }
415
416    #[test]
417    fn test_absolute_path() {
418        let address: Address = "/home/user/repo".parse().unwrap();
419        assert!(matches!(address, Address::Path(_)));
420        if let Address::Path(path) = address {
421            assert_eq!(path.path(), Path::new("/home/user/repo"));
422        }
423    }
424
425    #[test]
426    fn test_empty() {
427        assert!(matches!("".parse::<Address>(), Err(AddressError::Empty)));
428    }
429
430    #[test]
431    fn test_invalid() {
432        assert!(matches!(
433            "not-a-valid-url".parse::<Address>(),
434            Err(AddressError::InvalidFormat)
435        ));
436    }
437
438    #[test]
439    fn test_display() {
440        let address: Address = "git@github.com:user/repo.git".parse().unwrap();
441        assert_eq!(address.to_string(), "git@github.com:user/repo.git");
442        assert_eq!(address.as_str(), "git@github.com:user/repo.git");
443    }
444
445    #[test]
446    fn test_as_ref_os_str() {
447        let address: Address = "git@github.com:user/repo.git".parse().unwrap();
448        let os_str: &std::ffi::OsStr = address.as_ref();
449        assert_eq!(os_str, "git@github.com:user/repo.git");
450    }
451
452    #[test]
453    fn test_scp_empty_user() {
454        assert!(matches!(
455            "@github.com:path".parse::<Address>(),
456            Err(AddressError::InvalidFormat)
457        ));
458    }
459
460    #[test]
461    fn test_scp_empty_host() {
462        assert!(matches!(
463            "git@:path".parse::<Address>(),
464            Err(AddressError::InvalidFormat)
465        ));
466    }
467
468    #[test]
469    fn test_scp_empty_path() {
470        assert!(matches!(
471            "git@github.com:".parse::<Address>(),
472            Err(AddressError::InvalidFormat)
473        ));
474    }
475
476    #[test]
477    fn test_scp_path_with_leading_slash_rejected() {
478        // git@host:/path is not valid SCP (path starts with /) and not a valid URL
479        assert!(matches!(
480            "git@github.com:/user/repo".parse::<Address>(),
481            Err(AddressError::InvalidFormat)
482        ));
483    }
484
485    #[test]
486    fn test_ssh_url_missing_user() {
487        assert!(matches!(
488            "ssh://github.com/user/repo.git".parse::<Address>(),
489            Err(AddressError::InvalidSshAddress)
490        ));
491    }
492
493    #[test]
494    fn test_relative_path() {
495        assert!(matches!(
496            "./relative/path".parse::<Address>(),
497            Err(AddressError::InvalidFormat)
498        ));
499    }
500
501    #[test]
502    fn test_unknown_scheme() {
503        assert!(matches!(
504            "ftp://example.com/repo".parse::<Address>(),
505            Err(AddressError::InvalidFormat)
506        ));
507    }
508
509    #[test]
510    fn test_remote_name() {
511        let remote: Remote = "origin".parse().unwrap();
512        assert!(matches!(remote, Remote::Name(_)));
513        assert_eq!(remote.as_str(), "origin");
514    }
515
516    #[test]
517    fn test_remote_repository_address() {
518        let remote: Remote = "git@github.com:user/repo.git".parse().unwrap();
519        assert!(matches!(remote, Remote::RepositoryAddress(_)));
520        assert_eq!(remote.as_str(), "git@github.com:user/repo.git");
521    }
522
523    #[test]
524    fn test_remote_https_url() {
525        let remote: Remote = "https://github.com/user/repo.git".parse().unwrap();
526        assert!(matches!(remote, Remote::RepositoryAddress(_)));
527    }
528
529    #[test]
530    fn test_remote_empty() {
531        assert!(matches!(
532            "".parse::<Remote>(),
533            Err(RemoteNameError(RefFormatError::Empty))
534        ));
535    }
536
537    #[test]
538    fn test_remote_name_with_whitespace() {
539        assert!(matches!(
540            "origin upstream".parse::<Remote>(),
541            Err(RemoteNameError(RefFormatError::ContainsSpace))
542        ));
543    }
544
545    #[test]
546    fn test_remote_name_display() {
547        let name: RemoteName = "origin".parse().unwrap();
548        assert_eq!(name.to_string(), "origin");
549    }
550
551    #[test]
552    fn test_remote_from_remote_name() {
553        let name: RemoteName = "upstream".parse().unwrap();
554        let remote: Remote = name.into();
555        assert!(matches!(remote, Remote::Name(_)));
556    }
557
558    #[test]
559    fn test_remote_from_address() {
560        let address: Address = "git@github.com:user/repo.git".parse().unwrap();
561        let remote: Remote = address.into();
562        assert!(matches!(remote, Remote::RepositoryAddress(_)));
563    }
564
565    #[test]
566    fn test_remote_name_serialize() {
567        let name: RemoteName = "origin".parse().unwrap();
568        assert_eq!(serde_json::to_string(&name).unwrap(), "\"origin\"");
569    }
570
571    #[test]
572    fn test_remote_name_deserialize() {
573        let name: RemoteName = serde_json::from_str("\"origin\"").unwrap();
574        assert_eq!(name.as_str(), "origin");
575    }
576
577    #[test]
578    fn test_remote_name_deserialize_invalid() {
579        assert!(serde_json::from_str::<RemoteName>("\"bad name\"").is_err());
580    }
581}