Skip to main content

khive_pack_git/
refs.rs

1//! GitHub reference-grammar extraction (ADR-088 Amendment 1, ingest
2//! enrichment riders): `Closes/Fixes/Resolves #N` and bare `#N` mentions in
3//! commit messages and issue/PR bodies.
4//!
5//! Extraction never panics or errors -- fail-open per the amendment. A
6//! malformed shape (`#54abc`, a `#` with no digits after it) is simply not
7//! matched, not reported as an error.
8
9/// The two reference kinds materialized as `annotates` edge metadata
10/// (`ref_kind` on the edge -- see `crates/khive-pack-git/src/handlers.rs`).
11#[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
32/// GitHub's closing-keyword grammar (case-insensitive): `Closes`, `Fixes`,
33/// `Resolves` and their inflections, immediately preceding `#N` (whitespace
34/// and/or a colon allowed in between).
35const CLOSING_KEYWORDS: &[&str] = &[
36    "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved",
37];
38
39/// Extract every GitHub-style issue/PR reference from `text`.
40///
41/// A `#N` immediately preceded (skipping whitespace/`:`) by a closing
42/// keyword is `RefKind::Closes`; every other `#N` is `RefKind::Mentions`. A
43/// `#` not immediately followed by at least one digit, or a digit run
44/// immediately followed by another alphanumeric character (e.g. `#54abc`,
45/// not a clean reference), is skipped. May return duplicate `(number, kind)`
46/// pairs for text with repeated references -- callers that materialize one
47/// edge per referenced number should dedupe (see `dedupe_prefer_closes`).
48pub 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            // "#54abc" -- not a clean reference.
64            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
78/// Collapse duplicate `number`s to a single mention, preferring `Closes`
79/// over `Mentions` when both occur for the same number (a closing reference
80/// is strictly more informative).
81pub 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
112/// `true` when the `#` at `hash_idx` is immediately preceded (allowing
113/// trailing whitespace/`:` in between) by one of [`CLOSING_KEYWORDS`],
114/// case-insensitively, with a non-alphanumeric (or start-of-string)
115/// boundary before the keyword.
116fn 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
136/// Truncate `s` to at most `max_chars` characters (char-boundary safe, no
137/// ellipsis -- the amendment specifies "truncated", not a marker suffix).
138pub 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}