#![deny(clippy::unwrap_used)]
#[cfg(feature = "derive_builder")]
use derive_builder::Builder;
use percent_encoding::percent_decode_str;
use std::str;
use thiserror::Error;
use url::Url;
mod parser;
#[cfg(test)]
mod proptest;
static AUTH_SCHEMES: [&str; 5] = ["git", "https", "git+https", "http", "git+http"];
static KNOWN_SCHEMES: [&str; 10] = [
"http",
"https",
"git",
"git+ssh",
"git+https",
"ssh",
"bitbucket",
"gist",
"github",
"gitlab",
];
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
pub enum Provider {
BitBucket,
Gist,
GitHub,
GitLab,
}
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
pub enum DefaultRepresentation {
Shortcut,
Git,
Https,
Ssh,
Other,
}
impl DefaultRepresentation {
fn from_scheme(scheme: &str) -> DefaultRepresentation {
use DefaultRepresentation::*;
match scheme {
"git" => Git,
"git+https" => Https,
"git+ssh" => Ssh,
"https" => Https,
"ssh" => Ssh,
_ => Other,
}
}
}
#[derive(PartialEq, Eq, Clone, Copy, Debug, Error)]
pub enum ParseError {
#[error("Failed to parse URL")]
InvalidUrl(#[from] url::ParseError),
#[error("Failed to parse percent-encoded URI component")]
InvalidUriEncoding(#[from] str::Utf8Error),
#[error("Failed to recognize URL")]
UnknownUrl,
}
#[derive(Debug, Eq, PartialEq, Clone)]
#[cfg_attr(feature = "derive_builder", derive(Builder))]
pub struct HostedGitInfo {
provider: Provider,
#[cfg_attr(
feature = "derive_builder",
builder(setter(into, strip_option), default)
)]
user: Option<String>,
#[cfg_attr(
feature = "derive_builder",
builder(setter(into, strip_option), default)
)]
auth: Option<String>,
#[cfg_attr(feature = "derive_builder", builder(setter(into)))]
project: String,
#[cfg_attr(
feature = "derive_builder",
builder(setter(into, strip_option), default)
)]
committish: Option<String>,
#[cfg_attr(feature = "derive_builder", builder(setter(name = "repr")))]
default_representation: DefaultRepresentation,
}
impl HostedGitInfo {
pub fn from_url(giturl: &str) -> Result<Self, ParseError> {
let url = if is_github_shorthand(giturl) {
format!("github:{}", giturl)
} else {
correct_protocol(giturl)
};
let parsed = parse_git_url(&url)?;
let parser_from_shortcut = parser::parser_from_shortcut(parsed.scheme());
let simplified_domain = parsed
.domain()
.map(|domain| domain.strip_prefix("www.").unwrap_or(domain));
let parser_from_domain =
simplified_domain.and_then(|domain| parser::parser_from_domain(domain));
let parser = parser_from_shortcut
.as_ref()
.or_else(|| parser_from_domain.as_ref());
let parser = match parser {
Some(parser) => parser,
None => return Err(ParseError::UnknownUrl),
};
let username = match parsed.username() {
username if !username.is_empty() => Some(username),
_ => None,
};
let password = parsed.password();
let auth = if AUTH_SCHEMES.contains(&parsed.scheme()) {
match (username, password) {
(Some(username), Some(password)) => Some(format!("{}:{}", username, password)),
(Some(username), None) => Some(username.to_string()),
(None, Some(password)) => Some(format!(":{}", password)),
(None, None) => None,
}
} else {
None
};
if parser_from_shortcut.is_some() {
let path = parsed.path();
let mut pathname = path.strip_prefix('/').unwrap_or(path);
let first_at = pathname.find('@');
if let Some(first_at) = first_at {
pathname = &pathname[first_at + 1..];
}
let last_slash = pathname.rfind('/');
let (user, project) = if let Some(last_slash) = last_slash {
let user = percent_decode_str(&pathname[0..last_slash]).decode_utf8()?;
let user = if user.is_empty() { None } else { Some(user) };
let project = percent_decode_str(&pathname[last_slash + 1..]).decode_utf8()?;
(user, project)
} else {
let project = percent_decode_str(&pathname).decode_utf8()?;
(None, project)
};
let project = project
.strip_suffix(".git")
.unwrap_or_else(|| project.as_ref());
let committish = parsed
.fragment()
.map(|committish| percent_decode_str(&committish).decode_utf8())
.transpose()?;
Ok(Self {
provider: parser.provider(),
user: user.map(|s| s.to_string()),
auth,
project: project.to_string(),
committish: committish.map(|s| s.to_string()),
default_representation: DefaultRepresentation::Shortcut,
})
} else {
if !parser.supports_scheme(parsed.scheme()) {
return Err(ParseError::UnknownUrl);
}
let segments = parser.extract(&parsed)?;
let user = segments
.user
.map(|user| percent_decode_str(&user).decode_utf8())
.transpose()?;
let project = segments
.project
.map(|project| percent_decode_str(&project).decode_utf8())
.transpose()?
.ok_or(ParseError::UnknownUrl)?;
let committish = segments
.committish
.map(|committish| percent_decode_str(&committish).decode_utf8())
.transpose()?;
Ok(Self {
provider: parser.provider(),
user: user.map(|s| s.to_string()),
auth,
project: project.to_string(),
committish: committish.map(|s| s.to_string()),
default_representation: DefaultRepresentation::from_scheme(parsed.scheme()),
})
}
}
pub fn provider(&self) -> Provider {
self.provider
}
pub fn user(&self) -> Option<&str> {
self.user.as_deref()
}
pub fn auth(&self) -> Option<&str> {
self.auth.as_deref()
}
pub fn project(&self) -> &str {
&self.project
}
pub fn committish(&self) -> Option<&str> {
self.committish.as_deref()
}
pub fn default_representation(&self) -> DefaultRepresentation {
self.default_representation
}
}
impl str::FromStr for HostedGitInfo {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
HostedGitInfo::from_url(s)
}
}
fn is_github_shorthand(arg: &str) -> bool {
let first_hash = arg.find('#');
let first_slash = arg.find('/');
let second_slash = first_slash.and_then(|first_slash| arg[first_slash + 1..].find('/'));
let first_colon = arg.find(':');
let first_space = arg.find(char::is_whitespace);
let first_at = arg.find('@');
let space_only_after_hash =
first_space.is_none() || (first_hash.is_some() && first_space > first_hash);
let at_only_after_hash = first_at.is_none() || (first_hash.is_some() && first_at > first_hash);
let colon_only_after_hash =
first_colon.is_none() || (first_hash.is_some() && first_colon > first_hash);
let second_slash_only_after_hash =
second_slash.is_none() || (first_hash.is_some() && second_slash > first_hash);
let has_slash = matches!(first_slash, Some(first_slash) if first_slash > 0);
let does_not_end_with_slash = if let Some(first_hash) = first_hash {
first_hash == 0 || arg.as_bytes().get(first_hash - 1) != Some(&b'/')
} else {
!arg.ends_with('/')
};
let does_not_start_with_dot = !arg.starts_with('.');
space_only_after_hash
&& has_slash
&& does_not_end_with_slash
&& does_not_start_with_dot
&& at_only_after_hash
&& colon_only_after_hash
&& second_slash_only_after_hash
}
fn correct_protocol(arg: &str) -> String {
if let Some(first_colon) = arg.find(':') {
let proto = &arg[0..first_colon];
if KNOWN_SCHEMES.contains(&proto) {
return arg.to_string();
}
if let Some(first_at) = arg.find('@') {
return if first_at > first_colon {
format!("git+ssh://{}", arg)
} else {
arg.to_string()
};
}
let double_slash = arg.find("//");
if double_slash == Some(first_colon + 1) {
return arg.to_string();
}
format!("{}//{}", &arg[0..first_colon + 1], &arg[first_colon + 1..])
} else {
arg.to_string()
}
}
fn parse_git_url(giturl: &str) -> Result<Url, url::ParseError> {
Url::parse(giturl).or_else(|_error| {
let corrected_url = correct_url(giturl).ok_or(_error)?;
Url::parse(&corrected_url)
})
}
fn correct_url(giturl: &str) -> Option<String> {
let first_at = giturl.find('@');
let last_hash = giturl.rfind('#');
let mut first_colon = giturl.find(':');
let last_colon = last_hash
.map(|last_hash| &giturl[..last_hash])
.unwrap_or(giturl)
.rfind(':');
let mut corrected = None;
if let Some(last_colon_) = last_colon {
if last_colon > first_at {
let corrected_ = format!("{}/{}", &giturl[0..last_colon_], &giturl[last_colon_ + 1..]);
first_colon = corrected_.find(':');
corrected = Some(corrected_);
}
}
if first_colon.is_none() && !giturl.contains("//") {
corrected = corrected.map(|corrected| format!("git+ssh://{}", corrected));
}
corrected
}