1use std::borrow::Cow;
2use std::path::{Path, PathBuf};
3
4#[derive(Clone, Debug, Eq, PartialEq)]
6pub enum Address {
7 Ssh(SshAddress),
9 Https(HttpsUrl),
11 Git(GitUrl),
13 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 if let Some(address) = SshAddress::from_scp(input) {
51 return Ok(Self::Ssh(address));
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(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 if let Ok(address) = input.parse::<PathAddress>() {
67 return Ok(Self::Path(address));
68 }
69
70 Err(AddressError::InvalidFormat)
71 }
72}
73
74#[derive(Clone, Debug, Eq, PartialEq)]
76pub enum Remote {
77 Name(RemoteName),
79 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 if let Ok(address) = input.parse::<Address>() {
115 return Ok(Self::RepositoryAddress(address));
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<Address> for Remote {
130 fn from(address: Address) -> Self {
131 Self::RepositoryAddress(address)
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 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 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, 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#[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#[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 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}