use regex::Regex;
use std::collections::BTreeSet;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum RefKind {
Pr,
Commit,
}
impl RefKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Pr => "pr",
Self::Commit => "commit",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct ProvenanceRef {
pub(crate) kind: RefKind,
pub(crate) value: String,
}
pub(crate) fn extract_refs(text: &str) -> Vec<ProvenanceRef> {
let mut found: BTreeSet<ProvenanceRef> = BTreeSet::new();
for captures in pr_url_regex().captures_iter(text) {
push_pr(&mut found, &captures["num"]);
}
for captures in pr_hash_regex().captures_iter(text) {
push_pr(&mut found, &captures["num"]);
}
for captures in commit_regex().captures_iter(text) {
let sha = &captures["sha"];
if is_plausible_commit_sha(sha) {
found.insert(ProvenanceRef {
kind: RefKind::Commit,
value: sha.to_ascii_lowercase(),
});
}
}
found.into_iter().collect()
}
fn push_pr(found: &mut BTreeSet<ProvenanceRef>, number: &str) {
let normalized = number.trim_start_matches('0');
let normalized = if normalized.is_empty() {
"0"
} else {
normalized
};
found.insert(ProvenanceRef {
kind: RefKind::Pr,
value: format!("#{normalized}"),
});
}
fn is_plausible_commit_sha(sha: &str) -> bool {
if sha.bytes().all(|byte| byte.is_ascii_digit()) {
return false;
}
sha.bytes().any(|byte| byte.is_ascii_alphabetic())
}
fn pr_url_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(
r"(?i)\bgithub\.com/[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?/[A-Za-z0-9._-]+/pull/(?P<num>[0-9]{1,9})\b",
)
.expect("valid pr url regex")
})
}
fn pr_hash_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(
r"(?i)(?:^|[^0-9A-Za-z/#])(?:[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9._-]+)?(?:PR\s*)?#(?P<num>[0-9]{1,9})(?:[^0-9A-Za-z]|$)",
)
.expect("valid pr hash regex")
})
}
fn commit_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"(?i)(?:^|[^0-9a-fx])(?P<sha>[0-9a-f]{7,40})(?:[^0-9a-fx]|$)")
.expect("valid commit regex")
})
}
#[cfg(test)]
mod tests {
use super::*;
fn values(text: &str, kind: RefKind) -> Vec<String> {
extract_refs(text)
.into_iter()
.filter(|reference| reference.kind == kind)
.map(|reference| reference.value)
.collect()
}
#[test]
fn extracts_bare_hash_pr() {
assert_eq!(values("fixed in #54", RefKind::Pr), vec!["#54".to_string()]);
}
#[test]
fn extracts_pr_prefixed_reference() {
assert_eq!(
values("landed via PR #1234 today", RefKind::Pr),
vec!["#1234".to_string()]
);
}
#[test]
fn extracts_qualified_org_repo_reference() {
assert_eq!(
values("see suleymanozkeskin/nabu#73 for context", RefKind::Pr),
vec!["#73".to_string()]
);
}
#[test]
fn extracts_pull_url_reference() {
assert_eq!(
values(
"merged https://github.com/suleymanozkeskin/nabu/pull/60 cleanly",
RefKind::Pr,
),
vec!["#60".to_string()]
);
}
#[test]
fn extracts_pull_url_with_trailing_path_and_anchor() {
assert_eq!(
values(
"review github.com/org/repo/pull/75/files#diff-abc here",
RefKind::Pr,
),
vec!["#75".to_string()]
);
}
#[test]
fn pr_forms_normalize_to_one_value() {
let refs = values(
"#54 and org/repo#54 and https://github.com/org/repo/pull/54",
RefKind::Pr,
);
assert_eq!(refs, vec!["#54".to_string()]);
}
#[test]
fn zero_padded_pr_normalizes() {
assert_eq!(values("#0054", RefKind::Pr), vec!["#54".to_string()]);
}
#[test]
fn multiple_distinct_prs_sorted() {
let refs = values("touches #59, #54, and #73", RefKind::Pr);
assert_eq!(
refs,
vec!["#54".to_string(), "#59".to_string(), "#73".to_string()]
);
}
#[test]
fn hex_color_is_not_a_pr() {
assert!(values("background: #54abcd;", RefKind::Pr).is_empty());
}
#[test]
fn extracts_short_commit_sha() {
assert_eq!(
values("reverted in 1e0a357 earlier", RefKind::Commit),
vec!["1e0a357".to_string()]
);
}
#[test]
fn extracts_full_commit_sha() {
let sha = "100a8704bf3c2d1e5a6f7b8c9d0e1f2a3b4c5d6e";
assert_eq!(
values(&format!("at commit {sha}"), RefKind::Commit),
vec![sha.to_string()]
);
}
#[test]
fn commit_sha_is_lowercased() {
assert_eq!(
values("commit 1E0A357 there", RefKind::Commit),
vec!["1e0a357".to_string()]
);
}
#[test]
fn decimal_only_token_is_not_a_commit() {
assert!(values("error code 1234567 occurred", RefKind::Commit).is_empty());
assert!(values("port 8080443 open", RefKind::Commit).is_empty());
}
#[test]
fn hex_literal_prefix_is_not_a_commit() {
assert!(values("mask 0xdeadbeef applied", RefKind::Commit).is_empty());
}
#[test]
fn oversized_hex_blob_is_not_a_commit() {
let blob = "a".repeat(64);
assert!(values(&format!("digest {blob} stored"), RefKind::Commit).is_empty());
}
#[test]
fn too_short_hex_is_not_a_commit() {
assert!(values("ref abc123 only", RefKind::Commit).is_empty());
}
#[test]
fn uuid_segments_are_documented_boundary() {
let refs = values(
"id 550e8400-e29b-41d4-a716-446655440000 here",
RefKind::Commit,
);
assert_eq!(refs, vec!["550e8400".to_string()]);
}
#[test]
fn mixed_text_extracts_both_kinds() {
let refs = extract_refs("fix #54 shipped in 1e0a357");
assert_eq!(
refs,
vec![
ProvenanceRef {
kind: RefKind::Pr,
value: "#54".to_string(),
},
ProvenanceRef {
kind: RefKind::Commit,
value: "1e0a357".to_string(),
},
]
);
}
#[test]
fn empty_text_yields_nothing() {
assert!(extract_refs("").is_empty());
assert!(extract_refs("no refs in this plain sentence at all").is_empty());
}
}