1use fancy_regex::Regex as FancyRegex;
2use regex::Regex;
3use std::collections::HashSet;
4use std::sync::LazyLock;
5
6static LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
7 Regex::new(r"https?://github\.com/([a-zA-Z0-9_.\-]+/[a-zA-Z0-9_.\-]+)/(?:issues|pull)/(\d+)")
8 .unwrap()
9});
10
11static CROSS_RE: LazyLock<Regex> =
12 LazyLock::new(|| Regex::new(r"([a-zA-Z0-9_.\-]+/[a-zA-Z0-9_.\-]+)#(\d+)\b").unwrap());
13
14static SHORT_RE: LazyLock<FancyRegex> =
15 LazyLock::new(|| FancyRegex::new(r"(?<![a-zA-Z0-9/])#(\d+)\b").unwrap());
16
17pub fn validate_repo(repo_name: &str) -> Option<String> {
19 let parts: Vec<&str> = repo_name.split('/').collect();
20 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
21 return Some("Invalid repo name. Use 'org/repo' format, e.g. 'numpy/numpy'.".to_string());
22 }
23 None
24}
25
26pub fn extract_github_refs(text: &str, default_repo: &str) -> Vec<(String, u64)> {
29 if text.is_empty() {
30 return Vec::new();
31 }
32
33 let mut refs: HashSet<(String, u64)> = HashSet::new();
34
35 for cap in LINK_RE.captures_iter(text) {
37 if let (Some(repo), Some(num)) = (cap.get(1), cap.get(2)) {
38 if let Ok(n) = num.as_str().parse::<u64>() {
39 refs.insert((repo.as_str().to_string(), n));
40 }
41 }
42 }
43
44 for cap in CROSS_RE.captures_iter(text) {
46 if let (Some(repo), Some(num)) = (cap.get(1), cap.get(2)) {
47 if let Ok(n) = num.as_str().parse::<u64>() {
48 refs.insert((repo.as_str().to_string(), n));
49 }
50 }
51 }
52
53 let mut start = 0;
55 while start < text.len() {
56 match SHORT_RE.find_from_pos(text, start) {
57 Ok(Some(m)) => {
58 if let Ok(Some(cap)) = SHORT_RE.captures_from_pos(text, m.start()) {
59 if let Some(num_match) = cap.get(1) {
60 if let Ok(n) = num_match.as_str().parse::<u64>() {
61 refs.insert((default_repo.to_string(), n));
62 }
63 }
64 }
65 start = m.end();
66 }
67 _ => break,
68 }
69 }
70
71 let mut result: Vec<(String, u64)> = refs.into_iter().collect();
72 result.sort();
73 result
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 #[test]
81 fn test_validate_repo_valid() {
82 assert_eq!(validate_repo("numpy/numpy"), None);
83 }
84
85 #[test]
86 fn test_validate_repo_invalid() {
87 assert!(validate_repo("noslash").is_some());
88 assert!(validate_repo("/empty").is_some());
89 assert!(validate_repo("empty/").is_some());
90 }
91
92 #[test]
93 fn test_extract_refs() {
94 let text = "See #42 and https://github.com/org/repo/issues/10 and other/lib#5";
95 let refs = extract_github_refs(text, "default/repo");
96 assert!(refs.contains(&("default/repo".to_string(), 42)));
97 assert!(refs.contains(&("org/repo".to_string(), 10)));
98 assert!(refs.contains(&("other/lib".to_string(), 5)));
99 }
100}