1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum RefKind {
13 Closes,
14 Mentions,
15}
16
17impl RefKind {
18 pub fn as_str(&self) -> &'static str {
19 match self {
20 RefKind::Closes => "closes",
21 RefKind::Mentions => "mentions",
22 }
23 }
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RefMention {
28 pub number: u64,
29 pub kind: RefKind,
30}
31
32const CLOSING_KEYWORDS: &[&str] = &[
36 "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved",
37];
38
39pub fn extract_references(text: &str) -> Vec<RefMention> {
49 let bytes = text.as_bytes();
50 let mut mentions = Vec::new();
51 let mut i = 0usize;
52 while i < bytes.len() {
53 if bytes[i] != b'#' {
54 i += 1;
55 continue;
56 }
57 let hash_idx = i;
58 let Some((number, next)) = parse_number(text, hash_idx + 1) else {
59 i += 1;
60 continue;
61 };
62 if next < bytes.len() && bytes[next].is_ascii_alphanumeric() {
63 i = next;
65 continue;
66 }
67 let kind = if preceded_by_closing_keyword(text, hash_idx) {
68 RefKind::Closes
69 } else {
70 RefKind::Mentions
71 };
72 mentions.push(RefMention { number, kind });
73 i = next;
74 }
75 mentions
76}
77
78pub fn dedupe_prefer_closes(mentions: Vec<RefMention>) -> Vec<RefMention> {
82 let mut by_number: std::collections::BTreeMap<u64, RefKind> = std::collections::BTreeMap::new();
83 for m in mentions {
84 by_number
85 .entry(m.number)
86 .and_modify(|k| {
87 if m.kind == RefKind::Closes {
88 *k = RefKind::Closes;
89 }
90 })
91 .or_insert(m.kind);
92 }
93 by_number
94 .into_iter()
95 .map(|(number, kind)| RefMention { number, kind })
96 .collect()
97}
98
99fn parse_number(text: &str, start: usize) -> Option<(u64, usize)> {
100 let bytes = text.as_bytes();
101 let mut end = start;
102 while end < bytes.len() && bytes[end].is_ascii_digit() {
103 end += 1;
104 }
105 if end == start {
106 return None;
107 }
108 let n: u64 = text[start..end].parse().ok()?;
109 Some((n, end))
110}
111
112fn preceded_by_closing_keyword(text: &str, hash_idx: usize) -> bool {
117 let before = &text[..hash_idx];
118 let trimmed = before.trim_end_matches(|c: char| c.is_whitespace() || c == ':');
119 let lower = trimmed.to_ascii_lowercase();
120 for kw in CLOSING_KEYWORDS {
121 if let Some(boundary_idx) = lower.len().checked_sub(kw.len()) {
122 if &lower[boundary_idx..] == *kw {
123 let prev_char = lower[..boundary_idx].chars().next_back();
124 if prev_char
125 .map(|c| !c.is_ascii_alphanumeric())
126 .unwrap_or(true)
127 {
128 return true;
129 }
130 }
131 }
132 }
133 false
134}
135
136pub fn truncate_chars(s: &str, max_chars: usize) -> String {
139 if s.chars().count() <= max_chars {
140 return s.to_string();
141 }
142 s.chars().take(max_chars).collect()
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 fn numbers_of(kind: RefKind, mentions: &[RefMention]) -> Vec<u64> {
150 mentions
151 .iter()
152 .filter(|m| m.kind == kind)
153 .map(|m| m.number)
154 .collect()
155 }
156
157 #[test]
158 fn closes_keyword_variants_detected() {
159 for kw in [
160 "Closes",
161 "closes",
162 "Close",
163 "Fixes",
164 "fix",
165 "Resolved",
166 "resolves:",
167 ] {
168 let text = format!("{kw} #42");
169 let mentions = extract_references(&text);
170 assert_eq!(
171 mentions,
172 vec![RefMention {
173 number: 42,
174 kind: RefKind::Closes
175 }],
176 "{text:?}"
177 );
178 }
179 }
180
181 #[test]
182 fn bare_hash_number_is_mention() {
183 let mentions = extract_references("see #7 for context");
184 assert_eq!(
185 mentions,
186 vec![RefMention {
187 number: 7,
188 kind: RefKind::Mentions
189 }]
190 );
191 }
192
193 #[test]
194 fn multiple_references_in_one_text() {
195 let mentions = extract_references("Fixes #12, Resolves #34, see also #56");
196 assert_eq!(numbers_of(RefKind::Closes, &mentions), vec![12, 34]);
197 assert_eq!(numbers_of(RefKind::Mentions, &mentions), vec![56]);
198 }
199
200 #[test]
201 fn no_false_positive_on_number_immediately_followed_by_letters() {
202 let mentions = extract_references("see #54abc for the hex code, not an issue");
203 assert!(mentions.is_empty(), "{mentions:?}");
204 }
205
206 #[test]
207 fn no_false_positive_on_bare_hash_with_no_digits() {
208 let mentions = extract_references("a #hashtag not a number, and a lone # too");
209 assert!(mentions.is_empty(), "{mentions:?}");
210 }
211
212 #[test]
213 fn double_hash_still_extracts_the_digit_run() {
214 let mentions = extract_references("##54");
215 assert_eq!(
216 mentions,
217 vec![RefMention {
218 number: 54,
219 kind: RefKind::Mentions
220 }]
221 );
222 }
223
224 #[test]
225 fn dedupe_prefers_closes_over_mentions_for_same_number() {
226 let mentions = vec![
227 RefMention {
228 number: 9,
229 kind: RefKind::Mentions,
230 },
231 RefMention {
232 number: 9,
233 kind: RefKind::Closes,
234 },
235 ];
236 let deduped = dedupe_prefer_closes(mentions);
237 assert_eq!(
238 deduped,
239 vec![RefMention {
240 number: 9,
241 kind: RefKind::Closes
242 }]
243 );
244 }
245
246 #[test]
247 fn truncate_chars_leaves_short_strings_untouched() {
248 assert_eq!(truncate_chars("short", 120), "short");
249 }
250
251 #[test]
252 fn truncate_chars_cuts_long_strings_at_char_boundary() {
253 let long = "a".repeat(200);
254 let truncated = truncate_chars(&long, 120);
255 assert_eq!(truncated.chars().count(), 120);
256 }
257}