Skip to content

Commit b728012

Browse files
authored
feat: Add interoperable support for url::Url (#74)
Resolves #68 * Adds `GitUrl::parse_to_url` to only normalize url and send to `url::Url::parse` * Adds `TryFrom<&Url>` and `TryFrom<Url>` for `GitUrl` * Adds compatibility with `url::Url` for `GenericProvider`, `AzureDevOpsProvider`, and `GitLabProvider`
1 parent d2a4573 commit b728012

File tree

7 files changed

+754
-349
lines changed

7 files changed

+754
-349
lines changed

src/lib.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
//! - 🏗️ Host provider info extraction
1616
//! - Easy to implement trait [`GitProvider`](crate::types::provider::GitProvider) for custom provider parsing
1717
//! - Built-in support for multiple Git hosting providers
18-
//! * [Generic](crate::types::provider::GenericProvider) (`git@host:owner/repo.git` style urls)
19-
//! * [GitLab](crate::types::provider::GitLabProvider)
20-
//! * [Azure DevOps](crate::types::provider::AzureDevOpsProvider)
18+
//! * [Generic](crate::types::provider::generic::GenericProvider) (`git@host:owner/repo.git` style urls)
19+
//! * [GitLab](crate::types::provider::gitlab::GitLabProvider)
20+
//! * [Azure DevOps](crate::types::provider::azure_devops::AzureDevOpsProvider)
2121
//!
2222
//! ## Quick Example
2323
//!
@@ -90,7 +90,12 @@
9090
//! #### `url`
9191
//! (**enabled by default**)
9292
//!
93-
//! Uses [url](https://docs.rs/url/latest/) during parsing for full url validation
93+
//! `GitUrl` parsing finishes with [url](https://docs.rs/url/latest/) during parsing for full url validation
94+
//!
95+
//! [`GitUrl::parse_to_url`] will normalize an ssh-based url and return [`url::Url`](https://docs.rs/url/latest/url/struct.Url.html)
96+
//!
97+
//! You can use `url::Url` with the built-in [`GitProvider`](crate::types::provider::GitProvider) host parsers. See the `url_interop` tests for examples
98+
//!
9499
//!
95100
96101
pub mod types;

src/types/mod.rs

Lines changed: 78 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,6 @@ pub struct GitUrl {
6767
hint: GitUrlParseHint,
6868
}
6969

70-
/// Build the printable GitUrl from its components
71-
impl fmt::Display for GitUrl {
72-
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
73-
let git_url_str = self.display();
74-
75-
write!(f, "{git_url_str}",)
76-
}
77-
}
78-
7970
impl GitUrl {
8071
/// scheme name (i.e. `scheme://`)
8172
pub fn scheme(&self) -> Option<&str> {
@@ -130,7 +121,7 @@ impl GitUrl {
130121
}
131122

132123
/// This method rebuilds the printable GitUrl from its components.
133-
/// `url_compat` results in output that can be parsed by the `url` crate
124+
/// `url_compat` results in output that can be parsed by the [`url`](https://docs.rs/url/latest/url/) crate
134125
fn build_string(&self, url_compat: bool) -> String {
135126
let scheme = if self.print_scheme() || url_compat {
136127
if let Some(scheme) = self.scheme() {
@@ -176,27 +167,7 @@ impl GitUrl {
176167
let git_url_str = format!("{scheme}{auth_info}{host}{port}{path}");
177168
git_url_str
178169
}
179-
}
180-
181-
#[cfg(feature = "url")]
182-
impl TryFrom<&GitUrl> for Url {
183-
type Error = url::ParseError;
184-
fn try_from(value: &GitUrl) -> Result<Self, Self::Error> {
185-
// Since we don't fully implement any spec, we'll rely on the url crate
186-
Url::parse(&value.url_compat_display())
187-
}
188-
}
189-
190-
#[cfg(feature = "url")]
191-
impl TryFrom<GitUrl> for Url {
192-
type Error = url::ParseError;
193-
fn try_from(value: GitUrl) -> Result<Self, Self::Error> {
194-
// Since we don't fully implement any spec, we'll rely on the url crate
195-
Url::parse(&value.url_compat_display())
196-
}
197-
}
198170

199-
impl GitUrl {
200171
/// Returns `GitUrl` after removing all user info values
201172
pub fn trim_auth(&self) -> GitUrl {
202173
let mut new_giturl = self.clone();
@@ -219,8 +190,16 @@ impl GitUrl {
219190
/// # }
220191
/// ```
221192
pub fn parse(input: &str) -> Result<Self, GitUrlParseError> {
222-
let mut git_url_result = GitUrl::default();
193+
let git_url = Self::parse_to_git_url(input)?;
223194

195+
git_url.is_valid()?;
196+
197+
Ok(git_url)
198+
}
199+
200+
/// Internal parse to `GitUrl` without validation steps
201+
fn parse_to_git_url(input: &str) -> Result<Self, GitUrlParseError> {
202+
let mut git_url_result = GitUrl::default();
224203
// Error if there are null bytes within the url
225204
// https://github.com/tjtelan/git-url-parse-rs/issues/16
226205
if input.contains('\0') {
@@ -294,6 +273,31 @@ impl GitUrl {
294273
Ok(git_url_result)
295274
}
296275

276+
/// Normalize input into form that can be used by [`Url::parse`](https://docs.rs/url/latest/url/struct.Url.html#method.parse)
277+
///
278+
/// ```
279+
/// use git_url_parse::GitUrl;
280+
/// #[cfg(feature = "url")]
281+
/// use url::Url;
282+
///
283+
/// fn main() -> Result<(), git_url_parse::GitUrlParseError> {
284+
/// let ssh_url = GitUrl::parse_to_url("[email protected]:tjtelan/git-url-parse-rs.git")?;
285+
///
286+
/// assert_eq!(ssh_url.scheme(), "ssh");
287+
/// assert_eq!(ssh_url.username(), "git");
288+
/// assert_eq!(ssh_url.host_str(), Some("github.com"));
289+
/// assert_eq!(ssh_url.path(), "/tjtelan/git-url-parse-rs.git");
290+
/// Ok(())
291+
/// }
292+
/// ```
293+
///
294+
#[cfg(feature = "url")]
295+
pub fn parse_to_url(input: &str) -> Result<Url, GitUrlParseError> {
296+
let git_url = Self::parse_to_git_url(input)?;
297+
298+
Ok(Url::try_from(git_url)?)
299+
}
300+
297301
/// ```
298302
/// use git_url_parse::GitUrl;
299303
/// use git_url_parse::types::provider::GenericProvider;
@@ -380,3 +384,46 @@ impl GitUrl {
380384
Ok(())
381385
}
382386
}
387+
388+
/// Build the printable GitUrl from its components
389+
impl fmt::Display for GitUrl {
390+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
391+
let git_url_str = self.display();
392+
393+
write!(f, "{git_url_str}",)
394+
}
395+
}
396+
397+
#[cfg(feature = "url")]
398+
impl TryFrom<&GitUrl> for Url {
399+
type Error = url::ParseError;
400+
fn try_from(value: &GitUrl) -> Result<Self, Self::Error> {
401+
// Since we don't fully implement any spec, we'll rely on the url crate
402+
Url::parse(&value.url_compat_display())
403+
}
404+
}
405+
406+
#[cfg(feature = "url")]
407+
impl TryFrom<GitUrl> for Url {
408+
type Error = url::ParseError;
409+
fn try_from(value: GitUrl) -> Result<Self, Self::Error> {
410+
// Since we don't fully implement any spec, we'll rely on the url crate
411+
Url::parse(&value.url_compat_display())
412+
}
413+
}
414+
415+
#[cfg(feature = "url")]
416+
impl TryFrom<&Url> for GitUrl {
417+
type Error = GitUrlParseError;
418+
fn try_from(value: &Url) -> Result<Self, Self::Error> {
419+
GitUrl::parse(value.as_str())
420+
}
421+
}
422+
423+
#[cfg(feature = "url")]
424+
impl TryFrom<Url> for GitUrl {
425+
type Error = GitUrlParseError;
426+
fn try_from(value: Url) -> Result<Self, Self::Error> {
427+
GitUrl::parse(value.as_str())
428+
}
429+
}

src/types/provider/azure_devops.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
use super::GitProvider;
2+
use crate::types::GitUrlParseHint;
3+
use crate::{GitUrl, GitUrlParseError};
4+
5+
use getset::Getters;
6+
use nom::Parser;
7+
use nom::branch::alt;
8+
use nom::bytes::complete::{is_not, tag, take_until};
9+
use nom::combinator::opt;
10+
use nom::sequence::{preceded, separated_pair, terminated};
11+
#[cfg(feature = "serde")]
12+
use serde::{Deserialize, Serialize};
13+
#[cfg(feature = "url")]
14+
use url::Url;
15+
16+
/// Azure DevOps repository provider
17+
/// ## Supported URL Formats
18+
///
19+
/// - `https://dev.azure.com/org/project/_git/repo`
20+
/// - `[email protected]:v3/org/project/repo`
21+
///
22+
/// Example:
23+
///
24+
/// ```
25+
/// use git_url_parse::{GitUrl, GitUrlParseError};
26+
/// use git_url_parse::types::provider::AzureDevOpsProvider;
27+
///
28+
/// let test_url = "https://[email protected]/CompanyName/ProjectName/_git/RepoName";
29+
/// let parsed = GitUrl::parse(test_url).expect("URL parse failed");
30+
///
31+
/// let provider_info: AzureDevOpsProvider = parsed.provider_info().unwrap();
32+
///
33+
/// assert_eq!(provider_info.org(), "CompanyName");
34+
/// assert_eq!(provider_info.project(), "ProjectName");
35+
/// assert_eq!(provider_info.repo(), "RepoName");
36+
/// assert_eq!(provider_info.fullname(), "CompanyName/ProjectName/RepoName");
37+
/// ```
38+
///
39+
#[derive(Debug, PartialEq, Eq, Clone, Getters)]
40+
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
41+
#[getset(get = "pub")]
42+
pub struct AzureDevOpsProvider {
43+
/// Azure Devops organization name
44+
org: String,
45+
/// Azure Devops project name
46+
project: String,
47+
/// Azure Devops repo name
48+
repo: String,
49+
}
50+
51+
impl AzureDevOpsProvider {
52+
/// Helper method to get the full name of a repo: `{org}/{project}/{repo}`
53+
pub fn fullname(&self) -> String {
54+
format!("{}/{}/{}", self.org, self.project, self.repo)
55+
}
56+
57+
/// Parse the path of a http url for Azure Devops patterns
58+
fn parse_http_path(input: &str) -> Result<(&str, AzureDevOpsProvider), GitUrlParseError> {
59+
// Handle optional leading /
60+
let (input, _) = opt(tag("/")).parse(input)?;
61+
62+
// Parse org/project/repo
63+
let (input, (org, (project, repo))) = separated_pair(
64+
is_not("/"),
65+
tag("/"),
66+
separated_pair(
67+
is_not("/"),
68+
tag("/"),
69+
preceded(opt(tag("_git/")), is_not("")),
70+
),
71+
)
72+
.parse(input)?;
73+
74+
Ok((
75+
input,
76+
AzureDevOpsProvider {
77+
org: org.to_string(),
78+
project: project.to_string(),
79+
repo: repo.to_string(),
80+
},
81+
))
82+
}
83+
84+
/// Parse the path of an ssh url for Azure Devops patterns
85+
fn parse_ssh_path(input: &str) -> Result<(&str, AzureDevOpsProvider), GitUrlParseError> {
86+
// Handle optional leading /v3/ or v3/ prefix
87+
let (input, _) =
88+
opt(alt((preceded(tag("/"), tag("v3/")), take_until("/")))).parse(input)?;
89+
90+
let (input, _) = opt(tag("/")).parse(input)?;
91+
92+
// Parse org/project/repo
93+
let (input, (org, (project, repo))) = separated_pair(
94+
is_not("/"),
95+
tag("/"),
96+
separated_pair(
97+
is_not("/"),
98+
tag("/"),
99+
terminated(is_not("."), opt(tag(".git"))),
100+
),
101+
)
102+
.parse(input)?;
103+
104+
Ok((
105+
input,
106+
AzureDevOpsProvider {
107+
org: org.to_string(),
108+
project: project.to_string(),
109+
repo: repo.to_string(),
110+
},
111+
))
112+
}
113+
}
114+
115+
impl GitProvider<GitUrl, GitUrlParseError> for AzureDevOpsProvider {
116+
fn from_git_url(url: &GitUrl) -> Result<Self, GitUrlParseError> {
117+
let path = url.path();
118+
119+
let parsed = if url.hint() == GitUrlParseHint::Httplike {
120+
Self::parse_http_path(path)
121+
} else {
122+
Self::parse_ssh_path(path)
123+
};
124+
125+
parsed.map(|(_, provider)| provider)
126+
}
127+
}
128+
129+
#[cfg(feature = "url")]
130+
impl GitProvider<Url, GitUrlParseError> for AzureDevOpsProvider {
131+
fn from_git_url(url: &Url) -> Result<Self, GitUrlParseError> {
132+
let path = url.path();
133+
134+
let parsed = if url.scheme().contains("http") {
135+
Self::parse_http_path(path)
136+
} else {
137+
Self::parse_ssh_path(path)
138+
};
139+
140+
parsed.map(|(_, provider)| provider)
141+
}
142+
}

0 commit comments

Comments
 (0)