1use crate::ref_format::{self, RefFormatError};
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 = RemoteNameError;
107
108 fn from_str(input: &str) -> Result<Self, Self::Err> {
109 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 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#[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 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#[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#[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#[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 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}