1use std::fmt::Display;
2use std::sync::OnceLock;
3
4macro_rules! regex {
5 ($re:literal $(,)?) => {{
6 static RE: OnceLock<regex::Regex> = OnceLock::new();
7 RE.get_or_init(|| regex::Regex::new($re).unwrap())
8 }};
9}
10
11#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
13#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
14pub enum Issue {
15 Jira(String),
16 GitHub(String),
17}
18
19impl Issue {
20 pub fn parse_from_commit_message<S: AsRef<str>>(commit_message: S) -> Option<Issue> {
21 let regex_jira_issue = regex!(r"(?m)^(?:Closes )?Ticket:\s+(\S+)");
22 if let Some(jira_captures) = regex_jira_issue.captures(commit_message.as_ref()) {
23 return Some(Issue::Jira(
24 jira_captures[jira_captures.len() - 1].to_owned(),
25 ));
26 }
27
28 let regex_github_issue = regex!(r"(?im)^(?:closes|close|closed|fixes|fixed)\s+#(\d+)");
29 if let Some(github_captures) = regex_github_issue.captures(commit_message.as_ref()) {
30 return Some(Issue::GitHub(
31 github_captures[github_captures.len() - 1].to_owned(),
32 ));
33 }
34 None
35 }
36
37 pub fn issue_identifier(&self) -> &str {
38 match self {
39 Issue::Jira(ticket) => ticket,
40 Issue::GitHub(issue) => issue,
41 }
42 }
43}
44
45impl Display for Issue {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 write!(
48 f,
49 "{}{}",
50 match self {
51 Issue::Jira(_) => "Jira ",
52 Issue::GitHub(_) => "GitHub #",
53 },
54 self.issue_identifier()
55 )
56 }
57}
58
59#[cfg(test)]
60mod test {
61 use super::Issue;
62
63 #[test]
64 fn display_jira_issue() {
65 let issue = Issue::Jira("GD-0".to_string());
66 assert_eq!(format!("{issue}"), "Jira GD-0");
67 }
68
69 #[test]
70 fn display_github_issue() {
71 let issue = Issue::GitHub("123".to_string());
72 assert_eq!(format!("{issue}"), "GitHub #123");
73 }
74
75 macro_rules! test_parses {
76 ($unit_test:ident, $input:expr, $output:expr) => {
77 #[test]
78 fn $unit_test() {
79 let message = $input;
80 let issue = Issue::parse_from_commit_message(message);
81 assert!(
82 issue.is_some(),
83 "Expected to parse issue from commit message"
84 );
85 let issue = issue.unwrap();
86 assert_eq!(issue, $output);
87 }
88 };
89 }
90
91 test_parses!(
92 successfully_parse_jira_ticket_from_commit_message_without_newline,
93 r#"
94feat(foo): add hyperdrive
95
96Ticket: AB-123
97"#,
98 Issue::Jira("AB-123".to_string())
99 );
100
101 test_parses!(
102 successfully_parse_jira_ticket_from_commit_message_with_newline,
103 r#"
104feat(foo): add hyperdrive
105
106Ticket: AB-123
107
108"#,
109 Issue::Jira("AB-123".to_string())
110 );
111
112 test_parses!(
113 successfully_parse_jira_ticket_from_commit_message_with_trailer,
114 r#"
115feat(foo): add hyperdrive
116
117Ticket: AB-123
118Footer: http://example.com
119"#,
120 Issue::Jira("AB-123".to_string())
121 );
122
123 test_parses!(
124 successfully_parse_jira_ticket_closes_ticket_from_commit_message_without_newline,
125 r#"
126feat(foo): add hyperdrive
127
128Closes Ticket: AB-123
129"#,
130 Issue::Jira("AB-123".to_string())
131 );
132
133 test_parses!(
134 successfully_parse_jira_ticket_closes_ticket_from_commit_message_with_newline,
135 r#"
136feat(foo): add hyperdrive
137
138Closes Ticket: AB-123
139
140"#,
141 Issue::Jira("AB-123".to_string())
142 );
143
144 test_parses!(
145 successfully_parse_jira_ticket_closes_ticket_from_commit_message_with_trailer,
146 r#"
147feat(foo): add hyperdrive
148
149Closes Ticket: AB-123
150Footer: http://example.com
151"#,
152 Issue::Jira("AB-123".to_string())
153 );
154
155 test_parses!(
156 successfully_parse_github_issue_from_commit_message_without_newline,
157 r#"
158feat(foo): add hyperdrive
159
160Closes #123
161"#,
162 Issue::GitHub("123".to_string())
163 );
164
165 test_parses!(
166 successfully_parse_github_issue_from_commit_message_with_newline,
167 r#"
168feat(foo): add hyperdrive
169
170Closes #123
171"#,
172 Issue::GitHub("123".to_string())
173 );
174
175 test_parses!(
176 successfully_parse_github_issue_from_commit_message_with_trailer,
177 r#"
178feat(foo): add hyperdrive
179
180Closes #123
181 Footer: http://example.com
182"#,
183 Issue::GitHub("123".to_string())
184 );
185
186 test_parses!(
187 successfully_parse_github_issue_closes_ticket_from_commit_message_without_newline,
188 r#"
189feat(foo): add hyperdrive
190
191Closes #123
192"#,
193 Issue::GitHub("123".to_string())
194 );
195
196 test_parses!(
197 successfully_parse_github_issue_closes_ticket_from_commit_message_with_newline,
198 r#"
199feat(foo): add hyperdrive
200
201Closes #123
202
203"#,
204 Issue::GitHub("123".to_string())
205 );
206
207 test_parses!(
208 successfully_parse_github_issue_closes_ticket_from_commit_message_with_trailer,
209 r#"
210feat(foo): add hyperdrive
211
212Closes #123
213 Footer: http://example.com
214"#,
215 Issue::GitHub("123".to_string())
216 );
217
218 #[test]
219 fn unnsuccessfully_parse_from_commit_message() {
220 let message = "feat(foo): add hyperdrive";
221 let issue = Issue::parse_from_commit_message(message);
222 assert!(
223 issue.is_none(),
224 "Expected to find no issue to parse from commit message"
225 );
226 }
227
228 use proptest::prelude::*;
229
230 proptest! {
231 #[test]
232 fn display_is_never_empty(issue in prop_oneof![
233 "[A-Z]{2,6}-[0-9]{1,6}".prop_map(Issue::Jira),
234 "[0-9]{1,6}".prop_map(Issue::GitHub),
235 ]) {
236 let display = format!("{}", issue);
237 prop_assert!(!display.is_empty());
238 }
239
240 #[test]
241 fn display_then_parse_roundtrip_jira(
242 project in "[A-Z]{2,6}",
243 number in 1u32..=99999u32,
244 ) {
245 let id = format!("{project}-{number}");
246 let message = format!("feat: x\n\nTicket: {id}");
247 let parsed = Issue::parse_from_commit_message(&message);
248 prop_assert_eq!(parsed, Some(Issue::Jira(id)));
249 }
250
251 #[test]
252 fn display_then_parse_roundtrip_github(number in 1u32..=99999u32) {
253 let message = format!("fix: x\n\nCloses #{number}");
254 let parsed = Issue::parse_from_commit_message(&message);
255 prop_assert_eq!(parsed, Some(Issue::GitHub(number.to_string())));
256 }
257 }
258}