use std::collections::HashMap;
use travelagent_core::error::TrvError;
use travelagent_core::forge::{ForgeType, PrId};
#[derive(Debug)]
pub enum ForgeTarget {
ByNumber(u64),
ByUrl {
forge_type: ForgeType,
pr_id: PrId,
host: Option<String>,
},
}
pub fn parse_pr_arg(
arg: &str,
forge_hosts: Option<&HashMap<String, String>>,
) -> Result<ForgeTarget, TrvError> {
if let Ok(num) = arg.parse::<u64>() {
return Ok(ForgeTarget::ByNumber(num));
}
if let Some(target) = parse_forge_url(arg, forge_hosts) {
return Ok(target);
}
Err(TrvError::ForgeApi(format!(
"Cannot parse '{arg}' as a PR number or URL"
)))
}
fn parse_forge_url(
url: &str,
forge_hosts: Option<&HashMap<String, String>>,
) -> Option<ForgeTarget> {
let without_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let first_slash = without_scheme.find('/')?;
let host = &without_scheme[..first_slash];
let path = &without_scheme[first_slash + 1..];
if let Some(mr_idx) = path.find("/-/merge_requests/") {
let path_before = &path[..mr_idx];
let number_str = &path[mr_idx + "/-/merge_requests/".len()..];
let number: u64 = number_str.split('/').next()?.parse().ok()?;
let segments: Vec<&str> = path_before.split('/').collect();
if segments.len() < 2 {
return None;
}
let repo = segments.last()?.to_string();
let owner = segments[..segments.len() - 1].join("/");
let custom_host = if host == "gitlab.com" {
None
} else {
Some(host.to_string())
};
return Some(ForgeTarget::ByUrl {
forge_type: ForgeType::GitLab,
pr_id: PrId {
owner,
repo,
number,
},
host: custom_host,
});
}
let parts: Vec<&str> = path.split('/').collect();
if parts.len() >= 4 && parts[2] == "pull" {
let number: u64 = parts[3].parse().ok()?;
let forge_type = detect_forge_type(host, forge_hosts);
if forge_type != ForgeType::GitHub {
return None;
}
let custom_host = if host == "github.com" {
None
} else {
Some(host.to_string())
};
return Some(ForgeTarget::ByUrl {
forge_type: ForgeType::GitHub,
pr_id: PrId {
owner: parts[0].to_string(),
repo: parts[1].to_string(),
number,
},
host: custom_host,
});
}
None
}
pub fn detect_forge_from_remote(
forge_hosts: Option<&HashMap<String, String>>,
) -> Result<(ForgeType, String, String, Option<String>), TrvError> {
let output = std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.output()
.map_err(|e| TrvError::ForgeApi(format!("Failed to run git: {e}")))?;
if !output.status.success() {
return Err(TrvError::ForgeApi(
"No git remote 'origin' found. Run from a git repository or specify a PR URL."
.to_string(),
));
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
parse_remote_url(&url, forge_hosts)
}
pub fn parse_remote_url(
url: &str,
forge_hosts: Option<&HashMap<String, String>>,
) -> Result<(ForgeType, String, String, Option<String>), TrvError> {
if let Some(rest) = url.strip_prefix("git@") {
let colon_idx = rest
.find(':')
.ok_or_else(|| TrvError::ForgeApi("Invalid SSH URL format".to_string()))?;
let host = &rest[..colon_idx];
let path = rest[colon_idx + 1..].trim_end_matches(".git");
let (owner, repo) = split_owner_repo(path)?;
let forge_type = detect_forge_type(host, forge_hosts);
let custom_host = if host != "github.com" && host != "gitlab.com" {
Some(host.to_string())
} else {
None
};
return Ok((forge_type, owner, repo, custom_host));
}
if url.starts_with("https://") || url.starts_with("http://") {
let without_scheme = url
.split("://")
.nth(1)
.ok_or_else(|| TrvError::ForgeApi("Invalid URL format".to_string()))?;
let slash_idx = without_scheme
.find('/')
.ok_or_else(|| TrvError::ForgeApi("Invalid URL format: no path".to_string()))?;
let host = &without_scheme[..slash_idx];
let path = without_scheme[slash_idx + 1..].trim_end_matches(".git");
let (owner, repo) = split_owner_repo(path)?;
let forge_type = detect_forge_type(host, forge_hosts);
let custom_host = if host != "github.com" && host != "gitlab.com" {
Some(host.to_string())
} else {
None
};
return Ok((forge_type, owner, repo, custom_host));
}
Err(TrvError::ForgeApi(format!(
"Cannot parse remote URL: {url}"
)))
}
fn split_owner_repo(path: &str) -> Result<(String, String), TrvError> {
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() < 2 {
return Err(TrvError::ForgeApi(format!(
"Cannot parse owner/repo from: {path}"
)));
}
let repo = segments
.last()
.expect("segments.len() >= 2 checked above")
.to_string();
let owner = segments[..segments.len() - 1].join("/");
Ok((owner, repo))
}
fn detect_forge_type(host: &str, forge_hosts: Option<&HashMap<String, String>>) -> ForgeType {
if let Some(map) = forge_hosts
&& let Some(forge_str) = map.get(host)
{
return match forge_str.as_str() {
"github" => ForgeType::GitHub,
_ => ForgeType::GitLab,
};
}
if host == "github.com" || host.contains("github") {
ForgeType::GitHub
} else {
ForgeType::GitLab
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_pr_arg_number() {
let target = parse_pr_arg("123", None).unwrap();
match target {
ForgeTarget::ByNumber(n) => assert_eq!(n, 123),
_ => panic!("Expected ByNumber"),
}
}
#[test]
fn parse_pr_arg_github_url() {
let target = parse_pr_arg("https://github.com/octocat/hello-world/pull/42", None).unwrap();
match target {
ForgeTarget::ByUrl {
forge_type,
pr_id,
host,
} => {
assert_eq!(forge_type, ForgeType::GitHub);
assert_eq!(pr_id.owner, "octocat");
assert_eq!(pr_id.repo, "hello-world");
assert_eq!(pr_id.number, 42);
assert!(host.is_none());
}
_ => panic!("Expected ByUrl"),
}
}
#[test]
fn parse_pr_arg_gitlab_url() {
let target =
parse_pr_arg("https://gitlab.com/group/repo/-/merge_requests/99", None).unwrap();
match target {
ForgeTarget::ByUrl {
forge_type,
pr_id,
host,
} => {
assert_eq!(forge_type, ForgeType::GitLab);
assert_eq!(pr_id.owner, "group");
assert_eq!(pr_id.repo, "repo");
assert_eq!(pr_id.number, 99);
assert!(host.is_none());
}
_ => panic!("Expected ByUrl"),
}
}
#[test]
fn parse_pr_arg_invalid() {
let result = parse_pr_arg("not-a-url-or-number", None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Cannot parse"));
}
#[test]
fn parse_github_url_basic() {
let target = parse_forge_url("https://github.com/owner/repo/pull/1", None).unwrap();
match target {
ForgeTarget::ByUrl { pr_id, .. } => {
assert_eq!(pr_id.owner, "owner");
assert_eq!(pr_id.repo, "repo");
assert_eq!(pr_id.number, 1);
}
_ => panic!("Expected ByUrl"),
}
}
#[test]
fn parse_github_url_with_trailing_path() {
let target = parse_forge_url("https://github.com/owner/repo/pull/42/files", None).unwrap();
match target {
ForgeTarget::ByUrl { pr_id, .. } => assert_eq!(pr_id.number, 42),
_ => panic!("Expected ByUrl"),
}
}
#[test]
fn parse_github_url_not_a_pull() {
assert!(parse_forge_url("https://github.com/owner/repo/issues/1", None).is_none());
}
#[test]
fn parse_github_url_not_github() {
assert!(parse_forge_url("https://gitlab.com/owner/repo/pull/1", None).is_none());
}
#[test]
fn parse_gitlab_url_basic() {
let target =
parse_forge_url("https://gitlab.com/group/repo/-/merge_requests/5", None).unwrap();
match target {
ForgeTarget::ByUrl {
forge_type, pr_id, ..
} => {
assert_eq!(forge_type, ForgeType::GitLab);
assert_eq!(pr_id.owner, "group");
assert_eq!(pr_id.repo, "repo");
assert_eq!(pr_id.number, 5);
}
_ => panic!("Expected ByUrl"),
}
}
#[test]
fn parse_gitlab_url_nested_groups() {
let target = parse_forge_url(
"https://gitlab.acme.com/data/project/repo/-/merge_requests/77",
None,
)
.unwrap();
match target {
ForgeTarget::ByUrl { pr_id, host, .. } => {
assert_eq!(pr_id.owner, "data/project");
assert_eq!(pr_id.repo, "repo");
assert_eq!(pr_id.number, 77);
assert_eq!(host, Some("gitlab.acme.com".to_string()));
}
_ => panic!("Expected ByUrl"),
}
}
#[test]
fn parse_gitlab_url_http() {
let target = parse_forge_url(
"http://gitlab.example.com/org/project/-/merge_requests/3",
None,
)
.unwrap();
match target {
ForgeTarget::ByUrl { pr_id, .. } => {
assert_eq!(pr_id.owner, "org");
assert_eq!(pr_id.repo, "project");
assert_eq!(pr_id.number, 3);
}
_ => panic!("Expected ByUrl"),
}
}
#[test]
fn parse_gitlab_url_not_mr() {
assert!(parse_forge_url("https://gitlab.com/group/repo/-/issues/1", None).is_none());
}
#[test]
fn parse_self_hosted_github_pull_url() {
let mut hosts = HashMap::new();
hosts.insert("code.mycompany.com".to_string(), "github".to_string());
let target = parse_forge_url(
"https://code.mycompany.com/team/project/pull/55",
Some(&hosts),
)
.unwrap();
match target {
ForgeTarget::ByUrl {
forge_type,
pr_id,
host,
} => {
assert_eq!(forge_type, ForgeType::GitHub);
assert_eq!(pr_id.owner, "team");
assert_eq!(pr_id.repo, "project");
assert_eq!(pr_id.number, 55);
assert_eq!(host, Some("code.mycompany.com".to_string()));
}
_ => panic!("Expected ByUrl"),
}
}
#[test]
fn parse_self_hosted_gitlab_mr_url() {
let target = parse_forge_url(
"https://git.internal.io/ops/infra/-/merge_requests/12",
None,
)
.unwrap();
match target {
ForgeTarget::ByUrl {
forge_type,
pr_id,
host,
} => {
assert_eq!(forge_type, ForgeType::GitLab);
assert_eq!(pr_id.owner, "ops");
assert_eq!(pr_id.repo, "infra");
assert_eq!(pr_id.number, 12);
assert_eq!(host, Some("git.internal.io".to_string()));
}
_ => panic!("Expected ByUrl"),
}
}
#[test]
fn parse_remote_url_github_ssh() {
let (forge, owner, repo, host) =
parse_remote_url("git@github.com:octocat/hello-world.git", None).unwrap();
assert_eq!(forge, ForgeType::GitHub);
assert_eq!(owner, "octocat");
assert_eq!(repo, "hello-world");
assert!(host.is_none());
}
#[test]
fn parse_remote_url_github_https() {
let (forge, owner, repo, host) =
parse_remote_url("https://github.com/octocat/hello-world.git", None).unwrap();
assert_eq!(forge, ForgeType::GitHub);
assert_eq!(owner, "octocat");
assert_eq!(repo, "hello-world");
assert!(host.is_none());
}
#[test]
fn parse_remote_url_github_https_no_git_suffix() {
let (forge, owner, repo, _) =
parse_remote_url("https://github.com/octocat/hello-world", None).unwrap();
assert_eq!(forge, ForgeType::GitHub);
assert_eq!(owner, "octocat");
assert_eq!(repo, "hello-world");
}
#[test]
fn parse_remote_url_gitlab_ssh() {
let (forge, owner, repo, host) =
parse_remote_url("git@gitlab.com:group/project.git", None).unwrap();
assert_eq!(forge, ForgeType::GitLab);
assert_eq!(owner, "group");
assert_eq!(repo, "project");
assert!(host.is_none());
}
#[test]
fn parse_remote_url_gitlab_https() {
let (forge, owner, repo, host) =
parse_remote_url("https://gitlab.com/group/project.git", None).unwrap();
assert_eq!(forge, ForgeType::GitLab);
assert_eq!(owner, "group");
assert_eq!(repo, "project");
assert!(host.is_none());
}
#[test]
fn parse_remote_url_custom_gitlab_ssh() {
let (forge, owner, repo, host) =
parse_remote_url("git@gitlab.acme.com:data/project/repo.git", None).unwrap();
assert_eq!(forge, ForgeType::GitLab);
assert_eq!(owner, "data/project");
assert_eq!(repo, "repo");
assert_eq!(host, Some("gitlab.acme.com".to_string()));
}
#[test]
fn parse_remote_url_custom_gitlab_https() {
let (forge, owner, repo, host) =
parse_remote_url("https://gitlab.acme.com/data/project/repo.git", None).unwrap();
assert_eq!(forge, ForgeType::GitLab);
assert_eq!(owner, "data/project");
assert_eq!(repo, "repo");
assert_eq!(host, Some("gitlab.acme.com".to_string()));
}
#[test]
fn parse_remote_url_invalid() {
let result = parse_remote_url("svn://example.com/repo", None);
assert!(result.is_err());
}
#[test]
fn detect_forge_type_github() {
assert_eq!(detect_forge_type("github.com", None), ForgeType::GitHub);
}
#[test]
fn detect_forge_type_github_enterprise() {
assert_eq!(
detect_forge_type("github.mycompany.com", None),
ForgeType::GitHub
);
}
#[test]
fn detect_forge_type_gitlab() {
assert_eq!(detect_forge_type("gitlab.com", None), ForgeType::GitLab);
}
#[test]
fn detect_forge_type_custom_defaults_to_gitlab() {
assert_eq!(
detect_forge_type("code.example.com", None),
ForgeType::GitLab
);
}
#[test]
fn forge_hosts_map_overrides_heuristic() {
let mut hosts = HashMap::new();
hosts.insert("code.example.com".to_string(), "github".to_string());
assert_eq!(
detect_forge_type("code.example.com", None),
ForgeType::GitLab
);
assert_eq!(
detect_forge_type("code.example.com", Some(&hosts)),
ForgeType::GitHub
);
}
#[test]
fn forge_hosts_map_used_in_remote_url_parsing() {
let mut hosts = HashMap::new();
hosts.insert("code.internal.io".to_string(), "github".to_string());
let (forge, owner, repo, host) =
parse_remote_url("git@code.internal.io:team/app.git", Some(&hosts)).unwrap();
assert_eq!(forge, ForgeType::GitHub);
assert_eq!(owner, "team");
assert_eq!(repo, "app");
assert_eq!(host, Some("code.internal.io".to_string()));
}
}