use crate::GITHUB_COM;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitHubRepo {
pub host: String,
pub owner: String,
pub repo: String,
}
impl GitHubRepo {
pub fn api_base_uri(&self) -> Option<String> {
(self.host != GITHUB_COM).then(|| format!("https://{}/api/v3", self.host))
}
}
impl std::fmt::Display for GitHubRepo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.owner, self.repo)
}
}
pub fn is_accepted_host(host: &str, extra_host: Option<&str>) -> bool {
host == GITHUB_COM || extra_host.is_some_and(|extra| extra.eq_ignore_ascii_case(host))
}
pub fn parse_remote_url(url: &str) -> Option<GitHubRepo> {
if let Some(rest) = url.strip_prefix("ssh://") {
let (authority, path) = rest.split_once('/')?;
let host = strip_userinfo(authority);
let host = host.split_once(':').map_or(host, |(host, _)| host);
return build(host, path);
}
for scheme in ["https://", "http://"] {
if let Some(rest) = url.strip_prefix(scheme) {
let (authority, path) = rest.split_once('/')?;
return build(strip_userinfo(authority), path);
}
}
let (userinfo_host, path) = url.split_once(':')?;
let (_, host) = userinfo_host.rsplit_once('@')?;
build(host, path)
}
pub fn parse_github_url(url: &str, extra_host: Option<&str>) -> Option<GitHubRepo> {
parse_remote_url(url).filter(|parsed| is_accepted_host(&parsed.host, extra_host))
}
fn strip_userinfo(authority: &str) -> &str {
authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host)
}
fn build(host: &str, path: &str) -> Option<GitHubRepo> {
if host.is_empty() {
return None;
}
let (owner, repo) = parse_owner_repo(path)?;
Some(GitHubRepo {
host: host.to_ascii_lowercase(),
owner,
repo,
})
}
fn parse_owner_repo(path: &str) -> Option<(String, String)> {
let path = path.strip_suffix(".git").unwrap_or(path);
let path = path.strip_suffix('/').unwrap_or(path);
let mut parts = path.splitn(3, '/');
let owner = parts.next().filter(|s| !s.is_empty())?;
let repo = parts.next().filter(|s| !s.is_empty())?;
if parts.next().is_some() {
return None;
}
Some((owner.to_string(), repo.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn stakk() -> GitHubRepo {
GitHubRepo {
host: GITHUB_COM.into(),
owner: "glennib".into(),
repo: "stakk".into(),
}
}
const ENTERPRISE: &str = "github.example.com";
#[test]
fn https_with_git_suffix() {
let result = parse_github_url("https://github.com/glennib/stakk.git", None);
assert_eq!(result, Some(stakk()));
}
#[test]
fn https_without_git_suffix() {
let result = parse_github_url("https://github.com/glennib/stakk", None);
assert_eq!(result, Some(stakk()));
}
#[test]
fn ssh_with_git_suffix() {
let result = parse_github_url("git@github.com:glennib/stakk.git", None);
assert_eq!(result, Some(stakk()));
}
#[test]
fn ssh_without_git_suffix() {
let result = parse_github_url("git@github.com:glennib/stakk", None);
assert_eq!(result, Some(stakk()));
}
#[test]
fn https_with_trailing_slash() {
let result = parse_github_url("https://github.com/owner/repo/", None);
assert_eq!(
result,
Some(GitHubRepo {
host: GITHUB_COM.into(),
owner: "owner".into(),
repo: "repo".into(),
})
);
}
#[test]
fn non_github_https() {
let result = parse_github_url("https://gitlab.com/owner/repo.git", None);
assert_eq!(result, None);
}
#[test]
fn non_github_ssh() {
let result = parse_github_url("git@gitlab.com:owner/repo.git", None);
assert_eq!(result, None);
}
#[test]
fn empty_string() {
assert_eq!(parse_github_url("", None), None);
}
#[test]
fn missing_repo() {
assert_eq!(parse_github_url("https://github.com/owner", None), None);
}
#[test]
fn extra_path_segments() {
assert_eq!(
parse_github_url("https://github.com/owner/repo/extra", None),
None
);
}
#[test]
fn ssh_canonical_with_git_suffix() {
let result = parse_github_url("ssh://git@github.com/glennib/stakk.git", None);
assert_eq!(result, Some(stakk()));
}
#[test]
fn ssh_canonical_without_git_suffix() {
let result = parse_github_url("ssh://git@github.com/glennib/stakk", None);
assert_eq!(result, Some(stakk()));
}
#[test]
fn non_github_ssh_canonical() {
assert_eq!(
parse_github_url("ssh://git@gitlab.com/owner/repo.git", None),
None
);
}
#[test]
fn enterprise_https() {
let result = parse_github_url("https://github.example.com/org/repo.git", Some(ENTERPRISE));
assert_eq!(
result,
Some(GitHubRepo {
host: ENTERPRISE.into(),
owner: "org".into(),
repo: "repo".into(),
})
);
}
#[test]
fn enterprise_ssh_scp_style() {
let result = parse_github_url("git@github.example.com:org/repo.git", Some(ENTERPRISE));
assert_eq!(
result,
Some(GitHubRepo {
host: ENTERPRISE.into(),
owner: "org".into(),
repo: "repo".into(),
})
);
}
#[test]
fn enterprise_ssh_canonical_without_git_suffix() {
let result = parse_github_url("ssh://git@github.example.com/org/repo", Some(ENTERPRISE));
assert_eq!(
result,
Some(GitHubRepo {
host: ENTERPRISE.into(),
owner: "org".into(),
repo: "repo".into(),
})
);
}
#[test]
fn enterprise_rejected_without_configuration() {
assert_eq!(
parse_github_url("git@github.example.com:org/repo.git", None),
None
);
}
#[test]
fn github_com_still_accepted_when_enterprise_host_configured() {
let result = parse_github_url("git@github.com:glennib/stakk.git", Some(ENTERPRISE));
assert_eq!(result, Some(stakk()));
}
#[test]
fn configured_host_match_is_case_insensitive() {
let result = parse_github_url("git@GitHub.Example.COM:org/repo.git", Some(ENTERPRISE));
assert_eq!(
result,
Some(GitHubRepo {
host: ENTERPRISE.into(),
owner: "org".into(),
repo: "repo".into(),
})
);
}
#[test]
fn ssh_port_is_dropped() {
let result = parse_remote_url("ssh://git@github.example.com:2222/org/repo.git");
assert_eq!(result.map(|r| r.host), Some(ENTERPRISE.to_string()));
}
#[test]
fn https_port_is_kept() {
let result = parse_remote_url("https://github.example.com:8443/org/repo.git");
assert_eq!(
result.map(|r| r.host),
Some("github.example.com:8443".to_string())
);
}
#[test]
fn https_credentials_are_stripped() {
let result = parse_remote_url("https://user:token@github.example.com/org/repo.git");
assert_eq!(result.map(|r| r.host), Some(ENTERPRISE.to_string()));
}
#[test]
fn parse_remote_url_accepts_any_host() {
let result = parse_remote_url("git@gitlab.com:owner/repo.git");
assert_eq!(
result,
Some(GitHubRepo {
host: "gitlab.com".into(),
owner: "owner".into(),
repo: "repo".into(),
})
);
}
#[test]
fn parse_remote_url_rejects_non_url() {
assert_eq!(parse_remote_url("not a url"), None);
assert_eq!(parse_remote_url("/some/local/path"), None);
}
#[test]
fn api_base_uri_is_none_for_github_com() {
assert_eq!(stakk().api_base_uri(), None);
}
#[test]
fn api_base_uri_is_v3_for_enterprise() {
let repo = GitHubRepo {
host: ENTERPRISE.into(),
owner: "org".into(),
repo: "repo".into(),
};
assert_eq!(
repo.api_base_uri().as_deref(),
Some("https://github.example.com/api/v3")
);
}
#[test]
fn api_base_uri_keeps_an_https_port() {
let repo = GitHubRepo {
host: "github.example.com:8443".into(),
owner: "org".into(),
repo: "repo".into(),
};
assert_eq!(
repo.api_base_uri().as_deref(),
Some("https://github.example.com:8443/api/v3")
);
}
}