use anyhow::{anyhow, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueRef {
pub owner: String,
pub repo: String,
pub number: u64,
}
#[derive(Debug, Clone)]
pub struct RepoRef {
pub owner: String,
pub repo: String,
}
impl RepoRef {
pub fn matches(&self, other: &RepoRef) -> bool {
self.owner.eq_ignore_ascii_case(&other.owner) && self.repo.eq_ignore_ascii_case(&other.repo)
}
}
impl From<&IssueRef> for RepoRef {
fn from(r: &IssueRef) -> Self {
Self {
owner: r.owner.clone(),
repo: r.repo.clone(),
}
}
}
const ATOMGIT_MIRROR_HOSTS: &[&str] = &["atomgit.com", "gitcode.com"];
fn is_atomgit_host(host: &str) -> bool {
ATOMGIT_MIRROR_HOSTS
.iter()
.any(|h| host.eq_ignore_ascii_case(h))
}
pub fn parse_repo_url(url: &str) -> Option<RepoRef> {
let trimmed = url.trim();
if let Some(rest) = trimmed.strip_prefix("git@") {
let (host_with_port, path) = rest.split_once(':')?;
let host = strip_port(host_with_port);
if !is_atomgit_host(host) {
return None;
}
return split_owner_repo(path);
}
let without_scheme = if let Some(rest) = trimmed.strip_prefix("https://") {
rest
} else if let Some(rest) = trimmed.strip_prefix("http://") {
rest
} else if let Some(rest) = trimmed.strip_prefix("ssh://") {
rest
} else {
return None;
};
let after_userinfo = match without_scheme.split_once('@') {
Some((userinfo, rest)) if !userinfo.contains('/') => rest,
_ => without_scheme,
};
let (host_with_port, path) = after_userinfo.split_once('/')?;
let host = strip_port(host_with_port);
if !is_atomgit_host(host) {
return None;
}
split_owner_repo(path)
}
fn strip_port(host: &str) -> &str {
host.split_once(':').map(|(h, _)| h).unwrap_or(host)
}
fn split_owner_repo(path: &str) -> Option<RepoRef> {
let mut parts = path
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty());
let owner = parts.next()?.to_string();
let repo = parts.next()?.to_string();
let repo = repo.strip_suffix(".git").unwrap_or(&repo).to_string();
if owner.is_empty() || repo.is_empty() {
return None;
}
Some(RepoRef { owner, repo })
}
pub fn detect_cwd_atomgit_repo(cwd: &std::path::Path) -> std::io::Result<Option<RepoRef>> {
let mut cmd = std::process::Command::new("git");
cmd.args(["remote", "get-url", "origin"])
.current_dir(cwd);
crate::process_utils::suppress_console_window_sync(&mut cmd);
let output = cmd.output()?;
if !output.status.success() {
return Ok(None);
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
if url.is_empty() {
return Ok(None);
}
Ok(parse_repo_url(&url))
}
impl IssueRef {
pub fn parse(url: &str) -> Result<Self> {
let trimmed = url.trim();
let without_scheme = trimmed
.strip_prefix("https://")
.or_else(|| trimmed.strip_prefix("http://"))
.ok_or_else(|| anyhow!("issue URL must start with http(s)://"))?;
let path_only = without_scheme
.split(['?', '#'])
.next()
.unwrap_or(without_scheme);
let mut parts = path_only.split('/').filter(|s| !s.is_empty());
let host = parts
.next()
.ok_or_else(|| anyhow!("missing host in issue URL"))?;
let host = strip_port(host);
if !is_atomgit_host(host) {
return Err(anyhow!(
"only atomgit.com/gitcode.com issue URLs are supported (got host {})",
host
));
}
let owner = parts
.next()
.ok_or_else(|| anyhow!("missing owner in issue URL"))?
.to_string();
let repo = parts
.next()
.ok_or_else(|| anyhow!("missing repo in issue URL"))?
.to_string();
let issues_seg = parts
.next()
.ok_or_else(|| anyhow!("missing 'issues' segment in URL"))?;
if issues_seg != "issues" {
return Err(anyhow!(
"expected '/issues/' in URL, got '/{}/'",
issues_seg
));
}
let number_str = parts
.next()
.ok_or_else(|| anyhow!("missing issue number in URL"))?;
let number = number_str
.parse::<u64>()
.map_err(|_| anyhow!("issue number '{}' is not a positive integer", number_str))?;
Ok(Self {
owner,
repo,
number,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_canonical_url() {
let r = IssueRef::parse("https://atomgit.com/atomgit_atomcode/atomcode/issues/42").unwrap();
assert_eq!(r.owner, "atomgit_atomcode");
assert_eq!(r.repo, "atomcode");
assert_eq!(r.number, 42);
}
#[test]
fn parses_with_trailing_slash() {
let r = IssueRef::parse("https://atomgit.com/a/b/issues/1/").unwrap();
assert_eq!(r.number, 1);
}
#[test]
fn parses_with_query_and_fragment() {
let r = IssueRef::parse("https://atomgit.com/a/b/issues/7?x=1#comment").unwrap();
assert_eq!(r.number, 7);
}
#[test]
fn parses_gitcode_mirror_issue_url() {
let r = IssueRef::parse("https://gitcode.com/atomgit_atomcode/atomcode/issues/340")
.unwrap();
assert_eq!(r.owner, "atomgit_atomcode");
assert_eq!(r.repo, "atomcode");
assert_eq!(r.number, 340);
}
#[test]
fn parses_issue_url_with_host_port() {
let r = IssueRef::parse("https://gitcode.com:443/a/b/issues/8").unwrap();
assert_eq!(r.owner, "a");
assert_eq!(r.repo, "b");
assert_eq!(r.number, 8);
}
#[test]
fn rejects_non_atomgit_host() {
assert!(IssueRef::parse("https://github.com/a/b/issues/1").is_err());
}
#[test]
fn rejects_missing_number() {
assert!(IssueRef::parse("https://atomgit.com/a/b/issues").is_err());
}
#[test]
fn rejects_non_numeric() {
assert!(IssueRef::parse("https://atomgit.com/a/b/issues/abc").is_err());
}
#[test]
fn rejects_wrong_path() {
assert!(IssueRef::parse("https://atomgit.com/a/b/pulls/1").is_err());
}
#[test]
fn parse_repo_url_https() {
let r = parse_repo_url("https://atomgit.com/owner/repo.git").unwrap();
assert_eq!(r.owner, "owner");
assert_eq!(r.repo, "repo");
}
#[test]
fn parse_repo_url_https_no_git_suffix() {
let r = parse_repo_url("https://atomgit.com/owner/repo").unwrap();
assert_eq!(r.repo, "repo");
}
#[test]
fn parse_repo_url_ssh_shorthand() {
let r = parse_repo_url("git@atomgit.com:owner/repo.git").unwrap();
assert_eq!(r.owner, "owner");
assert_eq!(r.repo, "repo");
}
#[test]
fn parse_repo_url_ssh_full() {
let r = parse_repo_url("ssh://git@atomgit.com/owner/repo.git").unwrap();
assert_eq!(r.owner, "owner");
assert_eq!(r.repo, "repo");
}
#[test]
fn parse_repo_url_rejects_non_atomgit() {
assert!(parse_repo_url("https://github.com/foo/bar.git").is_none());
assert!(parse_repo_url("git@github.com:foo/bar.git").is_none());
}
#[test]
fn parse_repo_url_strips_oauth_userinfo() {
let r = parse_repo_url("https://oauth2:abc123token@atomgit.com/owner/repo.git").unwrap();
assert_eq!(r.owner, "owner");
assert_eq!(r.repo, "repo");
}
#[test]
fn parse_repo_url_strips_basic_auth_userinfo() {
let r = parse_repo_url("https://user:password@atomgit.com/owner/repo.git").unwrap();
assert_eq!(r.owner, "owner");
assert_eq!(r.repo, "repo");
}
#[test]
fn parse_repo_url_strips_user_only_userinfo() {
let r = parse_repo_url("https://alice@gitcode.com/atomgit_atomcode/atomcode").unwrap();
assert_eq!(r.owner, "atomgit_atomcode");
assert_eq!(r.repo, "atomcode");
}
#[test]
fn parse_repo_url_strips_host_port() {
let r = parse_repo_url("https://atomgit.com:443/owner/repo.git").unwrap();
assert_eq!(r.owner, "owner");
assert_eq!(r.repo, "repo");
}
#[test]
fn parse_repo_url_ssh_with_port() {
let r = parse_repo_url("ssh://git@atomgit.com:22/owner/repo.git").unwrap();
assert_eq!(r.owner, "owner");
assert_eq!(r.repo, "repo");
}
#[test]
fn parse_repo_url_oauth_token_does_not_match_non_atomgit() {
assert!(parse_repo_url("https://oauth2:TOKEN@github.com/foo/bar.git").is_none());
}
#[test]
fn parse_repo_url_accepts_gitcode_mirror() {
for url in [
"git@gitcode.com:atomgit_atomcode/atomcode.git",
"https://gitcode.com/atomgit_atomcode/atomcode.git",
"https://gitcode.com/atomgit_atomcode/atomcode",
"ssh://git@gitcode.com/atomgit_atomcode/atomcode.git",
] {
let r = parse_repo_url(url)
.unwrap_or_else(|| panic!("should parse gitcode mirror URL: {}", url));
assert_eq!(r.owner, "atomgit_atomcode");
assert_eq!(r.repo, "atomcode");
}
}
#[test]
fn repo_ref_matches_case_insensitive() {
let a = RepoRef {
owner: "Atomgit_Atomcode".into(),
repo: "AtomCode".into(),
};
let b = RepoRef {
owner: "atomgit_atomcode".into(),
repo: "atomcode".into(),
};
assert!(a.matches(&b));
}
#[test]
fn issue_ref_to_repo_ref() {
let r = IssueRef::parse("https://atomgit.com/o/r/issues/1").unwrap();
let rr: RepoRef = (&r).into();
assert_eq!(rr.owner, "o");
assert_eq!(rr.repo, "r");
}
}