use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitUrl {
scheme: String,
host: String,
port: Option<u16>,
owner: String,
repo: String,
reference: Option<String>,
path: Option<String>,
}
impl GitUrl {
pub fn parse(url: &str) -> Result<Self, GitUrlError> {
let url = url.trim();
if url.is_empty() {
return Err(GitUrlError::EmptyUrl);
}
let (url, reference) = if let Some(hash_pos) = url.find('#') {
let reference = url[hash_pos + 1..].to_string();
(&url[..hash_pos], Some(reference))
} else {
(url, None)
};
if url.starts_with("git@") {
return Self::parse_ssh(url, reference);
}
let scheme_end = url.find("://").ok_or(GitUrlError::InvalidFormat)?;
let scheme = url[..scheme_end].to_string();
let rest = &url[scheme_end + 3..];
let slash_pos = rest.find('/').ok_or(GitUrlError::MissingPath)?;
let host_part = &rest[..slash_pos];
let path_part = &rest[slash_pos + 1..];
let (host, port) = if let Some(colon_pos) = host_part.find(':') {
let host = host_part[..colon_pos].to_string();
let port_str = &host_part[colon_pos + 1..];
let port = port_str
.parse::<u16>()
.map_err(|_| GitUrlError::InvalidPort)?;
(host, Some(port))
} else {
(host_part.to_string(), None)
};
let path_part = if path_part.ends_with(".git") {
&path_part[..path_part.len() - 4]
} else {
path_part
};
let parts: Vec<&str> = path_part.split('/').collect();
if parts.len() < 2 {
return Err(GitUrlError::MissingRepo);
}
let owner = parts[0].to_string();
let repo = parts[1].to_string();
let path = if parts.len() > 2 {
Some(parts[2..].join("/"))
} else {
None
};
Ok(GitUrl {
scheme,
host,
port,
owner,
repo,
reference,
path,
})
}
fn parse_ssh(url: &str, reference: Option<String>) -> Result<Self, GitUrlError> {
let rest = &url[4..]; let colon_pos = rest.find(':').ok_or(GitUrlError::InvalidFormat)?;
let host = rest[..colon_pos].to_string();
let path_part = &rest[colon_pos + 1..];
let path_part = if path_part.ends_with(".git") {
&path_part[..path_part.len() - 4]
} else {
path_part
};
let parts: Vec<&str> = path_part.split('/').collect();
if parts.len() < 2 {
return Err(GitUrlError::MissingRepo);
}
let owner = parts[0].to_string();
let repo = parts[1].to_string();
let path = if parts.len() > 2 {
Some(parts[2..].join("/"))
} else {
None
};
Ok(GitUrl {
scheme: "ssh".to_string(),
host,
port: None,
owner,
repo,
reference,
path,
})
}
pub fn scheme(&self) -> &str {
&self.scheme
}
pub fn host(&self) -> &str {
&self.host
}
pub fn port(&self) -> Option<u16> {
self.port
}
pub fn owner(&self) -> &str {
&self.owner
}
pub fn repo(&self) -> &str {
&self.repo
}
pub fn reference(&self) -> Option<&str> {
self.reference.as_deref()
}
pub fn path(&self) -> Option<&str> {
self.path.as_deref()
}
pub fn to_https_url(&self) -> String {
let mut url = format!("https://{}/{}", self.host, self.owner);
url.push('/');
url.push_str(&self.repo);
if let Some(port) = self.port {
url = format!(
"https://{}:{}/{}/{}",
self.host, port, self.owner, self.repo
);
}
if let Some(ref reference) = self.reference {
url.push('#');
url.push_str(reference);
}
url
}
pub fn to_ssh_url(&self) -> String {
let mut url = format!("git@{}:{}/{}", self.host, self.owner, self.repo);
if let Some(ref reference) = self.reference {
url.push('#');
url.push_str(reference);
}
url
}
pub fn to_git_url(&self) -> String {
let mut url = format!("git://{}/{}/{}", self.host, self.owner, self.repo);
if let Some(ref reference) = self.reference {
url.push('#');
url.push_str(reference);
}
url
}
pub fn is_github(&self) -> bool {
self.host.contains("github.com")
}
pub fn is_gitlab(&self) -> bool {
self.host.contains("gitlab.com") || self.host.contains("gitlab")
}
pub fn is_bitbucket(&self) -> bool {
self.host.contains("bitbucket.org")
}
}
impl fmt::Display for GitUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.scheme.as_str() {
"ssh" => write!(f, "{}", self.to_ssh_url()),
"git" => write!(f, "{}", self.to_git_url()),
_ => write!(f, "{}", self.to_https_url()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitUrlError {
EmptyUrl,
InvalidFormat,
MissingPath,
MissingRepo,
InvalidPort,
}
impl fmt::Display for GitUrlError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GitUrlError::EmptyUrl => write!(f, "URL is empty"),
GitUrlError::InvalidFormat => write!(f, "Invalid URL format"),
GitUrlError::MissingPath => write!(f, "Missing repository path"),
GitUrlError::MissingRepo => write!(f, "Missing repository name"),
GitUrlError::InvalidPort => write!(f, "Invalid port number"),
}
}
}
impl std::error::Error for GitUrlError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_https_url() {
let url = GitUrl::parse("https://github.com/user/repo.git").unwrap();
assert_eq!(url.scheme(), "https");
assert_eq!(url.host(), "github.com");
assert_eq!(url.owner(), "user");
assert_eq!(url.repo(), "repo");
assert_eq!(url.port(), None);
assert_eq!(url.reference(), None);
}
#[test]
fn test_parse_ssh_url() {
let url = GitUrl::parse("git@github.com:user/repo.git").unwrap();
assert_eq!(url.scheme(), "ssh");
assert_eq!(url.host(), "github.com");
assert_eq!(url.owner(), "user");
assert_eq!(url.repo(), "repo");
}
#[test]
fn test_parse_with_branch() {
let url = GitUrl::parse("https://github.com/user/repo.git#main").unwrap();
assert_eq!(url.reference(), Some("main"));
}
#[test]
fn test_parse_with_port() {
let url = GitUrl::parse("https://gitlab.example.com:8443/user/repo.git").unwrap();
assert_eq!(url.host(), "gitlab.example.com");
assert_eq!(url.port(), Some(8443));
}
#[test]
fn test_to_https_url() {
let url = GitUrl::parse("git@github.com:user/repo.git").unwrap();
assert_eq!(url.to_https_url(), "https://github.com/user/repo");
}
#[test]
fn test_to_ssh_url() {
let url = GitUrl::parse("https://github.com/user/repo.git").unwrap();
assert_eq!(url.to_ssh_url(), "git@github.com:user/repo");
}
#[test]
fn test_is_github() {
let url = GitUrl::parse("https://github.com/user/repo.git").unwrap();
assert!(url.is_github());
assert!(!url.is_gitlab());
}
#[test]
fn test_invalid_url() {
assert_eq!(GitUrl::parse(""), Err(GitUrlError::EmptyUrl));
assert_eq!(GitUrl::parse("not-a-url"), Err(GitUrlError::InvalidFormat));
}
}