1use std::collections::HashSet;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ExtractedEntity {
24 pub entity: String,
25 pub etype: &'static str,
26}
27
28const MAX_ENTITIES_PER_CHUNK: usize = 64;
30const MIN_ENTITY_LEN: usize = 2;
31
32const TRIM: &[char] = &[
37 '"', '\'', '`', ',', ';', '.', '(', ')', '[', ']', '{', '}', '?', '!', '<', '>', '*', '|',
38 '=', '@', '#', '%', '\\',
39];
40
41pub fn extract_entities(content: &str, trigger_desc: Option<&str>) -> Vec<ExtractedEntity> {
43 let mut raw: Vec<(String, &'static str)> = Vec::new();
44 for text in [Some(content), trigger_desc].into_iter().flatten() {
45 collect_backtick_spans(text, &mut raw);
46 for tok in text.split_whitespace() {
47 if let Some(cl) = classify(tok) {
48 raw.push(cl);
49 }
50 }
51 }
52
53 let mut seen: HashSet<String> = HashSet::new();
54 let mut out: Vec<ExtractedEntity> = Vec::new();
55 for (entity, etype) in raw {
56 if entity.len() >= MIN_ENTITY_LEN && seen.insert(entity.clone()) {
57 out.push(ExtractedEntity { entity, etype });
58 if out.len() >= MAX_ENTITIES_PER_CHUNK {
59 break;
60 }
61 }
62 }
63 out
64}
65
66fn collect_backtick_spans(text: &str, raw: &mut Vec<(String, &'static str)>) {
70 let mut in_span = false;
71 let mut buf = String::new();
72 for ch in text.chars() {
73 if ch == '`' {
74 if in_span {
75 for piece in buf.split_whitespace() {
76 if let Some(cl) = classify(piece) {
77 raw.push(cl);
78 } else if let Some(sym) = as_identifier(piece) {
79 raw.push((sym, "symbol"));
80 }
81 }
82 buf.clear();
83 }
84 in_span = !in_span;
85 } else if in_span {
86 buf.push(ch);
87 }
88 }
89}
90
91fn classify(tok: &str) -> Option<(String, &'static str)> {
95 let pre = tok.trim_matches(TRIM);
97 if pre.starts_with("--") && pre.len() >= 4 && pre[2..].chars().all(is_ident_char) {
98 return Some((pre.to_lowercase(), "flag"));
99 }
100 let t = pre;
101 if t.len() < MIN_ENTITY_LEN {
102 return None;
103 }
104
105 if let Some((alpha, digits)) = split_alpha_digits(t) {
107 if (1..=4).contains(&alpha.len()) && digits.len() >= 3 {
108 return Some((t.to_lowercase(), "error"));
109 }
110 }
111
112 if t.contains("::") && t.chars().all(is_ident_char) {
114 return Some((t.to_lowercase(), "path"));
115 }
116 if t.contains('/') && t.contains('.') && t.chars().all(is_ident_char) {
118 return Some((t.trim_end_matches('/').to_lowercase(), "path"));
119 }
120
121 if (t.contains('_') || t.contains('-')) && is_identifier_token(t) {
123 return Some((t.to_lowercase(), "symbol"));
124 }
125 if is_camel_case(t) {
127 return Some((t.to_lowercase(), "symbol"));
128 }
129 None
130}
131
132fn as_identifier(tok: &str) -> Option<String> {
134 let t = tok.trim_matches(TRIM);
135 if t.len() >= MIN_ENTITY_LEN && is_identifier_token(t) {
136 Some(t.to_lowercase())
137 } else {
138 None
139 }
140}
141
142fn is_ident_char(c: char) -> bool {
143 c.is_alphanumeric() || matches!(c, '_' | '-' | ':' | '.' | '/')
144}
145
146fn is_identifier_token(t: &str) -> bool {
149 t.chars().all(is_ident_char) && t.chars().any(|c| c.is_alphabetic())
150}
151
152fn split_alpha_digits(t: &str) -> Option<(&str, &str)> {
155 let split = t.find(|c: char| c.is_ascii_digit())?;
156 let (alpha, digits) = t.split_at(split);
157 if !alpha.is_empty()
158 && alpha.chars().all(|c| c.is_ascii_alphabetic())
159 && !digits.is_empty()
160 && digits.chars().all(|c| c.is_ascii_digit())
161 {
162 Some((alpha, digits))
163 } else {
164 None
165 }
166}
167
168fn is_camel_case(t: &str) -> bool {
171 if !t.chars().all(|c| c.is_alphanumeric()) {
172 return false;
173 }
174 let bytes: Vec<char> = t.chars().collect();
175 bytes
176 .windows(2)
177 .any(|w| w[0].is_lowercase() && w[1].is_uppercase())
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 fn ents(content: &str) -> Vec<String> {
185 extract_entities(content, None)
186 .into_iter()
187 .map(|e| e.entity)
188 .collect()
189 }
190
191 #[test]
192 fn extracts_error_codes() {
193 assert!(ents("hit error E0277 while building").contains(&"e0277".to_string()));
194 assert!(!ents("bumped to 1.2.3 in 2026").contains(&"1.2.3".to_string()));
196 }
197
198 #[test]
199 fn extracts_flags_and_paths() {
200 let e = ents("run cargo build --release and edit core/src/kb/recall.rs");
201 assert!(e.contains(&"--release".to_string()));
202 assert!(e.contains(&"core/src/kb/recall.rs".to_string()));
203 }
204
205 #[test]
206 fn extracts_symbols_not_plain_words() {
207 let e = ents("call KnowledgeBase::recall via the snake_case helper get_deps");
208 assert!(e.contains(&"knowledgebase::recall".to_string()));
209 assert!(e.contains(&"snake_case".to_string()));
210 assert!(e.contains(&"get_deps".to_string()));
211 assert!(!e.contains(&"call".to_string()));
213 assert!(!e.contains(&"via".to_string()));
214 }
215
216 #[test]
217 fn backtick_spans_lower_the_bar() {
218 let e = ents("the `recall` method matters");
219 assert!(e.contains(&"recall".to_string()));
220 assert!(!ents("recall the method").contains(&"recall".to_string()));
222 }
223
224 #[test]
225 fn dedups_and_caps() {
226 let e = extract_entities("E0277 E0277 E0277", None);
227 assert_eq!(e.iter().filter(|x| x.entity == "e0277").count(), 1);
228 }
229}