bounty/
parse.rs

1use eyre::Result;
2use regex::Regex;
3use url::Url;
4
5#[derive(Debug, PartialEq, Eq)]
6pub struct RepoIssue {
7    pub owner: String,
8    pub repo: String,
9    pub issue_number: u64,
10}
11
12impl RepoIssue {
13    /// Parse a repository issue reference from various formats:
14    /// - Full URL: <https://github.com/owner/repo/issues/123>
15    /// - Domain URL: github.com/owner/repo/issues/123
16    /// - Path only: owner/repo/issues/123
17    /// - Short form: owner/repo/123
18    /// - Issue reference: owner/repo#123
19    pub fn parse(input: &str) -> Result<Self> {
20        // Try parsing as URL first
21        if let Ok(url) = Url::parse(input) {
22            return Self::from_url(&url);
23        }
24
25        // Try parsing as domain URL (add https:// prefix)
26        if input.starts_with("github.com/") {
27            if let Ok(url) = Url::parse(&format!("https://{input}")) {
28                return Self::from_url(&url);
29            }
30        }
31
32        // Try parsing as path or issue reference
33        Self::from_path(input)
34    }
35
36    fn from_url(url: &Url) -> Result<Self> {
37        let path = url.path().trim_start_matches('/');
38        Self::from_path(path)
39    }
40
41    fn from_path(path: &str) -> Result<Self> {
42        // Match patterns like:
43        // - owner/repo/issues/123
44        // - owner/repo/123
45        // - owner/repo#123
46        let re = Regex::new(r"^([^/]+)/([^/#]+)(?:/(?:issues/)?|#)(\d+)$").unwrap();
47
48        if let Some(caps) = re.captures(path) {
49            let owner = caps[1].to_string();
50            let repo = caps[2].to_string();
51            let issue_number = caps[3].parse()?;
52
53            Ok(Self {
54                owner,
55                repo,
56                issue_number,
57            })
58        } else {
59            Err(eyre::eyre!("Invalid repository issue reference format"))
60        }
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn test_parse_full_url() {
70        let input = "https://github.com/ghbountybot/cli/issues/2";
71        let result = RepoIssue::parse(input).unwrap();
72        assert_eq!(
73            result,
74            RepoIssue {
75                owner: "ghbountybot".to_string(),
76                repo: "cli".to_string(),
77                issue_number: 2
78            }
79        );
80    }
81
82    #[test]
83    fn test_parse_domain_url() {
84        let input = "github.com/ghbountybot/cli/issues/2";
85        let result = RepoIssue::parse(input).unwrap();
86        assert_eq!(
87            result,
88            RepoIssue {
89                owner: "ghbountybot".to_string(),
90                repo: "cli".to_string(),
91                issue_number: 2
92            }
93        );
94    }
95
96    #[test]
97    fn test_parse_path_only() {
98        let input = "ghbountybot/cli/issues/2";
99        let result = RepoIssue::parse(input).unwrap();
100        assert_eq!(
101            result,
102            RepoIssue {
103                owner: "ghbountybot".to_string(),
104                repo: "cli".to_string(),
105                issue_number: 2
106            }
107        );
108    }
109
110    #[test]
111    fn test_parse_short_form() {
112        let input = "ghbountybot/cli/2";
113        let result = RepoIssue::parse(input).unwrap();
114        assert_eq!(
115            result,
116            RepoIssue {
117                owner: "ghbountybot".to_string(),
118                repo: "cli".to_string(),
119                issue_number: 2
120            }
121        );
122    }
123
124    #[test]
125    fn test_parse_issue_reference() {
126        let input = "ghbountybot/cli#2";
127        let result = RepoIssue::parse(input).unwrap();
128        assert_eq!(
129            result,
130            RepoIssue {
131                owner: "ghbountybot".to_string(),
132                repo: "cli".to_string(),
133                issue_number: 2
134            }
135        );
136    }
137
138    #[test]
139    fn test_parse_invalid_input() {
140        let inputs = [
141            "not-a-url",
142            "ghbountybot",
143            "ghbountybot/",
144            "ghbountybot/cli",
145            "ghbountybot/cli/",
146            "ghbountybot/cli/issues",
147            "ghbountybot/cli/issues/",
148            "ghbountybot/cli/issues/abc",
149        ];
150
151        for input in inputs {
152            assert!(
153                RepoIssue::parse(input).is_err(),
154                "Expected error for input: {input}"
155            );
156        }
157    }
158}