use std::path::PathBuf;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RefKind {
Issue,
Pull,
MergeRequest,
}
impl RefKind {
pub fn index_segment(self) -> &'static str {
match self {
RefKind::Issue => "issues",
RefKind::Pull => "pulls",
RefKind::MergeRequest => "merge_requests",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RefTarget {
pub host: String,
pub org_or_group_path: String,
pub repo: String,
pub kind: RefKind,
pub number: u64,
}
impl RefTarget {
pub fn canonical_url(&self) -> String {
let segment = match self.kind {
RefKind::Issue => "issues",
RefKind::Pull => "pull",
RefKind::MergeRequest => "-/merge_requests",
};
format!(
"https://{}/{}/{}/{}/{}",
self.host, self.org_or_group_path, self.repo, segment, self.number
)
}
pub fn index_dir(&self) -> PathBuf {
let mut p = PathBuf::from("_index");
p.push(&self.host);
for segment in self.org_or_group_path.split('/').filter(|s| !s.is_empty()) {
p.push(segment);
}
p.push(&self.repo);
p.push(self.kind.index_segment());
p.push(self.number.to_string());
p
}
}
pub fn parse_ref_url(url: &str) -> Option<RefTarget> {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let (host, path) = rest.split_once('/')?;
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() < 4 {
return None;
}
let number: u64 = segments.last()?.parse().ok()?;
let kind_kw = segments.get(segments.len() - 2)?;
let kind = match *kind_kw {
"issues" => RefKind::Issue,
"pull" | "pulls" => RefKind::Pull,
"merge_requests" => RefKind::MergeRequest,
_ => return None,
};
let mut head = &segments[..segments.len() - 2];
if head.last() == Some(&"-") {
head = &head[..head.len() - 1];
}
if head.len() < 2 {
return None;
}
let repo = head[head.len() - 1].to_string();
let org_or_group_path = head[..head.len() - 1].join("/");
if org_or_group_path.is_empty() {
return None;
}
Some(RefTarget {
host: host.to_string(),
org_or_group_path,
repo,
kind,
number,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_github_issue() {
let t = parse_ref_url("https://github.com/graysurf/agent-runtime-kit/issues/126").unwrap();
assert_eq!(t.host, "github.com");
assert_eq!(t.org_or_group_path, "graysurf");
assert_eq!(t.repo, "agent-runtime-kit");
assert_eq!(t.kind, RefKind::Issue);
assert_eq!(t.number, 126);
assert_eq!(
t.index_dir(),
PathBuf::from("_index/github.com/graysurf/agent-runtime-kit/issues/126")
);
}
#[test]
fn parses_github_pull() {
let t = parse_ref_url("https://github.com/sympoies/nils-cli/pull/574").unwrap();
assert_eq!(t.kind, RefKind::Pull);
assert_eq!(t.number, 574);
assert_eq!(
t.index_dir(),
PathBuf::from("_index/github.com/sympoies/nils-cli/pulls/574")
);
}
#[test]
fn parses_gitlab_merge_request_with_dash_and_nested_group() {
let t =
parse_ref_url("https://gitlab.example.com/acme/platform/ingest/-/merge_requests/42")
.unwrap();
assert_eq!(t.host, "gitlab.example.com");
assert_eq!(t.org_or_group_path, "acme/platform");
assert_eq!(t.repo, "ingest");
assert_eq!(t.kind, RefKind::MergeRequest);
assert_eq!(t.number, 42);
assert_eq!(
t.index_dir(),
PathBuf::from("_index/gitlab.example.com/acme/platform/ingest/merge_requests/42")
);
}
#[test]
fn parses_gitlab_issue_with_dash() {
let t = parse_ref_url("https://gitlab.example.com/acme/ingest/-/issues/7").unwrap();
assert_eq!(t.org_or_group_path, "acme");
assert_eq!(t.repo, "ingest");
assert_eq!(t.kind, RefKind::Issue);
}
#[test]
fn rejects_non_numeric_id() {
assert!(parse_ref_url("https://github.com/org/repo/issues/abc").is_none());
}
#[test]
fn rejects_unknown_kind() {
assert!(parse_ref_url("https://github.com/org/repo/wiki/123").is_none());
}
#[test]
fn rejects_missing_repo() {
assert!(parse_ref_url("https://github.com/org/issues/1").is_none());
}
#[test]
fn canonical_url_round_trips_kind() {
let t = parse_ref_url("https://github.com/org/repo/pull/9").unwrap();
assert_eq!(t.canonical_url(), "https://github.com/org/repo/pull/9");
}
}