use color_eyre::eyre::{eyre, WrapErr};
pub use color_eyre::Result;
use regex::Regex;
use std::fmt;
use std::str::FromStr;
use strum_macros::{Display, EnumString, EnumVariantNames};
use tracing::debug;
use url::Url;
#[derive(Debug, PartialEq, Eq, EnumString, EnumVariantNames, Clone, Display, Copy)]
#[strum(serialize_all = "kebab_case")]
pub enum Scheme {
File,
Ftp,
Ftps,
Git,
#[strum(serialize = "git+ssh")]
GitSsh,
Http,
Https,
Ssh,
Unspecified,
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct GitUrl {
pub host: Option<String>,
pub name: String,
pub owner: Option<String>,
pub organization: Option<String>,
pub fullname: String,
pub scheme: Scheme,
pub user: Option<String>,
pub token: Option<String>,
pub port: Option<u16>,
pub path: String,
pub git_suffix: bool,
pub scheme_prefix: bool,
}
impl fmt::Display for GitUrl {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let scheme_prefix = match self.scheme_prefix {
true => format!("{}://", self.scheme),
false => String::new(),
};
let auth_info = match self.scheme {
Scheme::Ssh | Scheme::Git | Scheme::GitSsh => {
if let Some(user) = &self.user {
format!("{}@", user)
} else {
String::new()
}
}
Scheme::Http | Scheme::Https => match (&self.user, &self.token) {
(Some(user), Some(token)) => format!("{}:{}@", user, token),
(Some(user), None) => format!("{}@", user),
(None, Some(token)) => format!("{}@", token),
(None, None) => String::new(),
},
_ => String::new(),
};
let host = match &self.host {
Some(host) => host.to_string(),
None => String::new(),
};
let port = match &self.port {
Some(p) => format!(":{}", p),
None => String::new(),
};
let path = match &self.scheme {
Scheme::Ssh => {
if self.port.is_some() {
format!("/{}", &self.path)
} else {
format!(":{}", &self.path)
}
}
_ => (&self.path).to_string(),
};
let git_url_str = format!("{}{}{}{}{}", scheme_prefix, auth_info, host, port, path);
write!(f, "{}", git_url_str)
}
}
impl Default for GitUrl {
fn default() -> Self {
GitUrl {
host: None,
name: "".to_string(),
owner: None,
organization: None,
fullname: "".to_string(),
scheme: Scheme::Unspecified,
user: None,
token: None,
port: None,
path: "".to_string(),
git_suffix: false,
scheme_prefix: false,
}
}
}
impl FromStr for GitUrl {
type Err = color_eyre::Report;
fn from_str(s: &str) -> Result<Self, Self::Err> {
GitUrl::parse(s)
}
}
impl GitUrl {
pub fn trim_auth(&self) -> GitUrl {
let mut new_giturl = self.clone();
new_giturl.user = None;
new_giturl.token = None;
new_giturl
}
pub fn parse(url: &str) -> Result<GitUrl> {
let normalized = normalize_url(url)
.with_context(|| "Url normalization into url::Url failed".to_string())?;
let scheme = Scheme::from_str(normalized.scheme())
.with_context(|| format!("Scheme unsupported: {:?}", normalized.scheme()))?;
let urlpath = match &scheme {
Scheme::Ssh => {
normalized.path()[1..].to_string()
}
_ => normalized.path().to_string(),
};
let git_suffix_check = &urlpath.ends_with(".git");
debug!("The urlpath: {:?}", &urlpath);
let splitpath = &urlpath.rsplit_terminator('/').collect::<Vec<&str>>();
debug!("rsplit results for metadata: {:?}", splitpath);
let name = splitpath[0].trim_end_matches(".git").to_string();
let (owner, organization, fullname) = match &scheme {
Scheme::File => (None::<String>, None::<String>, name.clone()),
_ => {
let mut fullname: Vec<&str> = Vec::new();
let hosts_w_organization_in_path = vec!["dev.azure.com", "ssh.dev.azure.com"];
let host_str = normalized
.host_str()
.ok_or(eyre!("Host from URL could not be represented as str"))?;
match hosts_w_organization_in_path.contains(&host_str) {
true => {
debug!("Found a git provider with an org");
match &scheme {
Scheme::Ssh => {
fullname.push(splitpath[2]);
fullname.push(splitpath[1]);
fullname.push(splitpath[0]);
(
Some(splitpath[1].to_string()),
Some(splitpath[2].to_string()),
fullname.join("/"),
)
}
Scheme::Https => {
fullname.push(splitpath[3]);
fullname.push(splitpath[2]);
fullname.push(splitpath[0]);
(
Some(splitpath[2].to_string()),
Some(splitpath[3].to_string()),
fullname.join("/"),
)
}
_ => return Err(eyre!("Scheme not supported for host")),
}
}
false => {
if !url.starts_with("ssh") && splitpath.len() < 2 {
return Err(eyre!("git url is not of expected format"));
}
let position = match splitpath.len() {
0 => return Err(eyre!("git url is not of expected format")),
1 => 0,
_ => 1,
};
fullname.push(splitpath[position]);
fullname.push(name.as_str());
(
Some(splitpath[position].to_string()),
None::<String>,
fullname.join("/"),
)
}
}
}
};
let final_host = match scheme {
Scheme::File => None,
_ => normalized.host_str().map(|h| h.to_string()),
};
let final_path = match scheme {
Scheme::File => {
if let Some(host) = normalized.host_str() {
format!("{}{}", host, urlpath)
} else {
urlpath
}
}
_ => urlpath,
};
Ok(GitUrl {
host: final_host,
name,
owner,
organization,
fullname,
scheme,
user: match normalized.username().to_string().len() {
0 => None,
_ => Some(normalized.username().to_string()),
},
token: normalized.password().map(|p| p.to_string()),
port: normalized.port(),
path: final_path,
git_suffix: *git_suffix_check,
scheme_prefix: url.contains("://") || url.starts_with("git:"),
})
}
}
fn normalize_ssh_url(url: &str) -> Result<Url> {
let u = url.split(':').collect::<Vec<&str>>();
match u.len() {
2 => {
debug!("Normalizing ssh url: {:?}", u);
normalize_url(&format!("ssh://{}/{}", u[0], u[1]))
}
3 => {
debug!("Normalizing ssh url with ports: {:?}", u);
normalize_url(&format!("ssh://{}:{}/{}", u[0], u[1], u[2]))
}
_default => Err(eyre!("SSH normalization pattern not covered for: {:?}", u)),
}
}
#[cfg(any(unix, windows, target_os = "redox", target_os = "wasi"))]
fn normalize_file_path(filepath: &str) -> Result<Url> {
let fp = Url::from_file_path(filepath);
match fp {
Ok(path) => Ok(path),
Err(_e) => Ok(normalize_url(&format!("file://{}", filepath))
.with_context(|| "file:// normalization failed".to_string())?),
}
}
#[cfg(target_arch = "wasm32")]
fn normalize_file_path(_filepath: &str) -> Result<Url> {
unreachable!()
}
pub fn normalize_url(url: &str) -> Result<Url> {
debug!("Processing: {:?}", &url);
if url.contains('\0') {
return Err(eyre!("Found null bytes within input url before parsing"));
}
let trim_url = url.trim_end_matches('/');
let url_to_parse = if Regex::new(r"^git:[^/]")
.with_context(|| "Failed to build short git url regex for testing against url".to_string())?
.is_match(trim_url)
{
trim_url.replace("git:", "git://")
} else {
trim_url.to_string()
};
let url_parse = Url::parse(&url_to_parse);
Ok(match url_parse {
Ok(u) => {
match Scheme::from_str(u.scheme()) {
Ok(_p) => u,
Err(_e) => {
debug!("Scheme parse fail. Assuming a userless ssh url");
normalize_ssh_url(trim_url).with_context(|| {
"No url scheme was found, then failed to normalize as ssh url.".to_string()
})?
}
}
}
Err(_e) => {
let re = Regex::new(r"^\S+(@)\S+(:).*$").with_context(|| {
"Failed to build ssh git url regex for testing against url".to_string()
})?;
match re.is_match(trim_url) {
true => {
debug!("Scheme::SSH match for normalization");
normalize_ssh_url(trim_url)
.with_context(|| "Failed to normalize as ssh url".to_string())?
}
false => {
debug!("Scheme::File match for normalization");
normalize_file_path(trim_url)
.with_context(|| "Failed to normalize as file url".to_string())?
}
}
}
})
}