#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteUrlParts {
pub domain: String,
pub user: String,
pub repo: String,
}
#[must_use]
pub fn parse_remote_url(url: &str) -> Option<RemoteUrlParts> {
let url = url.trim();
if url.is_empty() {
return None;
}
if let Some(parts) = parse_ssh_url(url) {
return Some(parts);
}
if let Some(parts) = parse_protocol_url(url) {
return Some(parts);
}
None
}
fn parse_ssh_url(url: &str) -> Option<RemoteUrlParts> {
let rest = url.strip_prefix("git@")?;
let (domain, path) = rest.split_once(':')?;
if domain.is_empty() {
return None;
}
let (user, repo) = parse_user_repo(path)?;
Some(RemoteUrlParts {
domain: domain.to_string(),
user,
repo,
})
}
fn parse_protocol_url(url: &str) -> Option<RemoteUrlParts> {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("git://"))?;
let (domain, path) = rest.split_once('/')?;
if domain.is_empty() {
return None;
}
let (user, repo) = parse_user_repo(path)?;
Some(RemoteUrlParts {
domain: domain.to_string(),
user,
repo,
})
}
fn parse_user_repo(path: &str) -> Option<(String, String)> {
let (user, repo_with_suffix) = path.split_once('/')?;
if user.is_empty() || repo_with_suffix.is_empty() {
return None;
}
let repo = repo_with_suffix
.strip_suffix(".git")
.unwrap_or(repo_with_suffix);
if repo.is_empty() {
return None;
}
Some((user.to_string(), repo.to_string()))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case(
"git@github.com:oshiteku/tazuna.git",
"github.com",
"oshiteku",
"tazuna"
)]
#[case("git@github.com:user/repo.git", "github.com", "user", "repo")]
#[case(
"git@gitlab.com:company/project.git",
"gitlab.com",
"company",
"project"
)]
#[case("git@bitbucket.org:team/repo.git", "bitbucket.org", "team", "repo")]
fn parse_ssh_urls(
#[case] url: &str,
#[case] domain: &str,
#[case] user: &str,
#[case] repo: &str,
) {
let parts = parse_remote_url(url).expect("should parse SSH URL");
assert_eq!(parts.domain, domain);
assert_eq!(parts.user, user);
assert_eq!(parts.repo, repo);
}
#[rstest]
#[case("https://github.com/user/repo.git", "github.com", "user", "repo")]
#[case("https://github.com/user/repo", "github.com", "user", "repo")]
#[case(
"https://gitlab.com/company/project.git",
"gitlab.com",
"company",
"project"
)]
#[case("https://bitbucket.org/team/repo.git", "bitbucket.org", "team", "repo")]
fn parse_https_urls(
#[case] url: &str,
#[case] domain: &str,
#[case] user: &str,
#[case] repo: &str,
) {
let parts = parse_remote_url(url).expect("should parse HTTPS URL");
assert_eq!(parts.domain, domain);
assert_eq!(parts.user, user);
assert_eq!(parts.repo, repo);
}
#[rstest]
#[case("git://github.com/user/repo.git", "github.com", "user", "repo")]
fn parse_git_protocol_urls(
#[case] url: &str,
#[case] domain: &str,
#[case] user: &str,
#[case] repo: &str,
) {
let parts = parse_remote_url(url).expect("should parse git:// URL");
assert_eq!(parts.domain, domain);
assert_eq!(parts.user, user);
assert_eq!(parts.repo, repo);
}
#[test]
fn strips_git_suffix_from_repo() {
let parts = parse_remote_url("git@github.com:user/my-repo.git").unwrap();
assert_eq!(parts.repo, "my-repo");
let parts = parse_remote_url("https://github.com/user/my-repo.git").unwrap();
assert_eq!(parts.repo, "my-repo");
}
#[rstest]
#[case("")]
#[case("not-a-url")]
#[case("file:///local/path")]
#[case("/absolute/path")]
#[case("relative/path")]
#[case("git@github.com")]
#[case("https://github.com")]
#[case("https://github.com/user")]
fn parse_invalid_urls(#[case] url: &str) {
assert!(
parse_remote_url(url).is_none(),
"expected None for invalid URL: {url}"
);
}
#[test]
fn remote_url_parts_derive_traits() {
let parts = RemoteUrlParts {
domain: "github.com".to_string(),
user: "user".to_string(),
repo: "repo".to_string(),
};
let cloned = parts.clone();
assert_eq!(parts, cloned);
assert_eq!(format!("{parts:?}"), format!("{cloned:?}"));
}
}