use url::Url;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
pub struct CanonicalUrl(Url);
impl CanonicalUrl {
pub fn new(url: &Url) -> Self {
let mut url = url.clone();
if url.cannot_be_a_base() {
return Self(url);
}
if !url.has_host() {
return Self(url);
}
let _ = url.set_password(None);
if !url.scheme().contains("ssh") {
let _ = url.set_username("");
}
if url.path().ends_with('/') {
url.path_segments_mut()
.expect("url should be a base")
.pop_if_empty();
}
if url.host_str() == Some("github.com") {
url.set_scheme(url.scheme().to_lowercase().as_str())
.expect("we should be able to set scheme");
let path = url.path().to_lowercase();
url.set_path(&path);
}
if let Some((prefix, suffix)) = url.path().rsplit_once('@') {
let needs_chopping = std::path::Path::new(prefix)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("git"));
if needs_chopping {
let prefix = &prefix[..prefix.len() - 4];
url.set_path(&format!("{prefix}@{suffix}"));
}
} else {
let needs_chopping = std::path::Path::new(url.path())
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("git"));
if needs_chopping {
let last = {
let last = url.path_segments().unwrap().next_back().unwrap();
last[..last.len() - 4].to_owned()
};
url.path_segments_mut().unwrap().pop().push(&last);
}
}
Self(url)
}
pub fn parse(url: &str) -> Result<Self, url::ParseError> {
Ok(Self::new(&Url::parse(url)?))
}
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Hash, serde::Serialize)]
#[serde(transparent)]
pub struct RepositoryUrl(Url);
impl<'de> serde::Deserialize<'de> for RepositoryUrl {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let url = Url::deserialize(deserializer)?;
Ok(RepositoryUrl::new(&url))
}
}
impl RepositoryUrl {
pub fn new(url: &Url) -> Self {
let mut url = CanonicalUrl::new(url).0;
let mut url = if url.scheme().starts_with("git+") {
if let Some(prefix) = url
.path()
.rsplit_once('@')
.map(|(prefix, _suffix)| prefix.to_string())
{
url.set_path(&prefix);
}
let url_as_str = &url.as_str()[4..];
Url::parse(url_as_str).expect("url should be valid")
} else {
url
};
url.set_fragment(None);
url.set_query(None);
Self(url)
}
pub fn parse(url: &str) -> Result<Self, url::ParseError> {
Ok(Self::new(&Url::parse(url)?))
}
pub fn into_url(self) -> Url {
self.into()
}
pub fn as_url(&self) -> &Url {
&self.0
}
}
pub fn redact_credentials(url: &mut Url) {
if url.scheme() == "ssh" && url.username() == "git" && url.password().is_none() {
return;
}
let _ = url.set_password(None);
let _ = url.set_username("");
}
impl From<RepositoryUrl> for Url {
fn from(url: RepositoryUrl) -> Self {
url.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_credential_does_not_affect_canonical_url() -> Result<(), url::ParseError> {
let url_without_creds =
CanonicalUrl::parse("https://example.com/pypa/sample-namespace-packages.git@2.0.0")?;
let url_with_creds = CanonicalUrl::parse(
"https://user:foo@example.com/pypa/sample-namespace-packages.git@2.0.0",
)?;
assert_eq!(
url_without_creds, url_with_creds,
"URLs with no user credentials should be the same as URLs with different user credentials",
);
let url_with_only_password = CanonicalUrl::parse(
"https://:bar@example.com/pypa/sample-namespace-packages.git@2.0.0",
)?;
assert_eq!(
url_with_creds, url_with_only_password,
"URLs with no username, though with a password, should be the same as URLs with different user credentials",
);
let url_with_username = CanonicalUrl::parse(
"https://user:@example.com/pypa/sample-namespace-packages.git@2.0.0",
)?;
assert_eq!(
url_with_creds, url_with_username,
"URLs with no password, though with a username, should be the same as URLs with different user credentials",
);
Ok(())
}
#[test]
fn canonical_url() -> Result<(), url::ParseError> {
assert_eq!(
CanonicalUrl::parse("git+https://github.com/pypa/sample-namespace-packages.git")?,
CanonicalUrl::parse("git+https://github.com/pypa/sample-namespace-packages")?,
);
assert_eq!(
CanonicalUrl::parse("git+https://github.com/pypa/sample-namespace-packages.git@2.0.0")?,
CanonicalUrl::parse("git+https://github.com/pypa/sample-namespace-packages@2.0.0")?,
);
assert_ne!(
CanonicalUrl::parse("git+https://github.com/pypa/sample-namespace-packages.git")?,
CanonicalUrl::parse("git+https://github.com/pypa/sample-packages.git")?,
);
assert_ne!(
CanonicalUrl::parse(
"git+https://github.com/pypa/sample-namespace-packages.git#subdirectory=pkg_resources/pkg_a"
)?,
CanonicalUrl::parse(
"git+https://github.com/pypa/sample-namespace-packages.git#subdirectory=pkg_resources/pkg_b"
)?,
);
assert_ne!(
CanonicalUrl::parse(
"git+https://github.com/pypa/sample-namespace-packages.git@v1.0.0"
)?,
CanonicalUrl::parse(
"git+https://github.com/pypa/sample-namespace-packages.git@v2.0.0"
)?,
);
assert_eq!(
CanonicalUrl::parse("git+https:://github.com/pypa/sample-namespace-packages.git")?,
CanonicalUrl::parse("git+https:://github.com/pypa/sample-namespace-packages.git")?,
);
Ok(())
}
#[test]
fn repository_url() -> Result<(), url::ParseError> {
assert_eq!(
RepositoryUrl::parse("git+https://github.com/pypa/sample-namespace-packages.git")?,
RepositoryUrl::parse("git+https://github.com/pypa/sample-namespace-packages")?,
);
assert_eq!(
RepositoryUrl::parse(
"git+https://github.com/pypa/sample-namespace-packages.git@2.0.0"
)?,
RepositoryUrl::parse("git+https://github.com/pypa/sample-namespace-packages@2.0.0")?,
);
assert_ne!(
RepositoryUrl::parse("git+https://github.com/pypa/sample-namespace-packages.git")?,
RepositoryUrl::parse("git+https://github.com/pypa/sample-packages.git")?,
);
assert_eq!(
RepositoryUrl::parse(
"git+https://github.com/pypa/sample-namespace-packages.git#subdirectory=pkg_resources/pkg_a"
)?,
RepositoryUrl::parse(
"git+https://github.com/pypa/sample-namespace-packages.git#subdirectory=pkg_resources/pkg_b"
)?,
);
assert_eq!(
RepositoryUrl::parse(
"git+https://github.com/pypa/sample-namespace-packages.git@v1.0.0"
)?,
RepositoryUrl::parse(
"git+https://github.com/pypa/sample-namespace-packages.git@v2.0.0"
)?,
);
Ok(())
}
}