1use serde::{Deserialize, Serialize};
28
29#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
37pub struct TaskClass {
38 pub id: String,
40 pub name: String,
42 pub signal_keywords: Vec<String>,
47}
48
49impl TaskClass {
50 pub fn new(
52 id: impl Into<String>,
53 name: impl Into<String>,
54 signal_keywords: impl IntoIterator<Item = impl Into<String>>,
55 ) -> Self {
56 Self {
57 id: id.into(),
58 name: name.into(),
59 signal_keywords: signal_keywords
60 .into_iter()
61 .map(|k| k.into().to_lowercase())
62 .collect(),
63 }
64 }
65
66 pub(crate) fn overlap_score(&self, signal: &str) -> usize {
71 let tokens = tokenise(signal);
72 self.signal_keywords
73 .iter()
74 .filter(|kw| tokens.contains(*kw))
75 .count()
76 }
77}
78
79pub fn builtin_task_classes() -> Vec<TaskClass> {
86 vec![
87 TaskClass::new(
88 "missing-import",
89 "Missing import / undefined symbol",
90 [
91 "e0425",
92 "e0433",
93 "unresolved",
94 "undefined",
95 "import",
96 "missing",
97 "cannot",
98 "find",
99 "symbol",
100 ],
101 ),
102 TaskClass::new(
103 "type-mismatch",
104 "Type mismatch",
105 [
106 "e0308",
107 "mismatched",
108 "expected",
109 "found",
110 "type",
111 "mismatch",
112 ],
113 ),
114 TaskClass::new(
115 "borrow-conflict",
116 "Borrow checker conflict",
117 [
118 "e0502", "e0505", "borrow", "lifetime", "moved", "cannot", "conflict",
119 ],
120 ),
121 TaskClass::new(
122 "test-failure",
123 "Test failure",
124 ["test", "failed", "panic", "assert", "assertion", "failure"],
125 ),
126 TaskClass::new(
127 "performance",
128 "Performance issue",
129 ["slow", "latency", "timeout", "perf", "performance", "hot"],
130 ),
131 ]
132}
133
134pub struct TaskClassMatcher {
138 classes: Vec<TaskClass>,
139}
140
141impl TaskClassMatcher {
142 pub fn new(classes: Vec<TaskClass>) -> Self {
144 Self { classes }
145 }
146
147 pub fn with_builtins() -> Self {
149 Self::new(builtin_task_classes())
150 }
151
152 pub fn classify<'a>(&'a self, signals: &[String]) -> Option<&'a TaskClass> {
156 let mut best: Option<(&TaskClass, usize)> = None;
157
158 for class in &self.classes {
159 let total_score: usize = signals.iter().map(|s| class.overlap_score(s)).sum();
160 if total_score > 0 {
161 match best {
162 None => best = Some((class, total_score)),
163 Some((_, prev_score)) if total_score > prev_score => {
164 best = Some((class, total_score));
165 }
166 _ => {}
167 }
168 }
169 }
170
171 best.map(|(c, _)| c)
172 }
173
174 pub fn classes(&self) -> &[TaskClass] {
176 &self.classes
177 }
178}
179
180fn tokenise(s: &str) -> Vec<String> {
184 s.split(|c: char| !c.is_alphanumeric())
185 .filter(|t| !t.is_empty())
186 .map(|t| t.to_lowercase())
187 .collect()
188}
189
190pub fn signals_match_class(signals: &[String], class_id: &str, registry: &[TaskClass]) -> bool {
194 let matcher = TaskClassMatcher::new(registry.to_vec());
195 matcher
196 .classify(signals)
197 .map_or(false, |c| c.id == class_id)
198}
199
200#[cfg(test)]
203mod tests {
204 use super::*;
205
206 fn matcher() -> TaskClassMatcher {
207 TaskClassMatcher::with_builtins()
208 }
209
210 #[test]
213 fn test_missing_import_via_error_code() {
214 let m = matcher();
215 let signals = vec!["error[E0425]: cannot find value `foo` in scope".to_string()];
216 let cls = m.classify(&signals).expect("should classify");
217 assert_eq!(cls.id, "missing-import");
218 }
219
220 #[test]
221 fn test_missing_import_via_natural_language() {
222 let m = matcher();
223 let signals = vec!["undefined symbol: use_missing_fn".to_string()];
225 let cls = m.classify(&signals).expect("should classify");
226 assert_eq!(cls.id, "missing-import");
227 }
228
229 #[test]
230 fn test_missing_import_via_unresolved_import() {
231 let m = matcher();
232 let signals = vec!["unresolved import `std::collections::Missing`".to_string()];
233 let cls = m.classify(&signals).expect("should classify");
234 assert_eq!(cls.id, "missing-import");
235 }
236
237 #[test]
238 fn test_type_mismatch_classification() {
239 let m = matcher();
240 let signals =
241 vec!["error[E0308]: mismatched types: expected `u32` found `String`".to_string()];
242 let cls = m.classify(&signals).expect("should classify");
243 assert_eq!(cls.id, "type-mismatch");
244 }
245
246 #[test]
247 fn test_borrow_conflict_classification() {
248 let m = matcher();
249 let signals = vec![
250 "error[E0502]: cannot borrow `x` as mutable because it is also borrowed as immutable"
251 .to_string(),
252 ];
253 let cls = m.classify(&signals).expect("should classify");
254 assert_eq!(cls.id, "borrow-conflict");
255 }
256
257 #[test]
258 fn test_test_failure_classification() {
259 let m = matcher();
260 let signals = vec!["test panicked: assertion failed: x == y".to_string()];
261 let cls = m.classify(&signals).expect("should classify");
262 assert_eq!(cls.id, "test-failure");
263 }
264
265 #[test]
266 fn test_multiple_signals_accumulate_score() {
267 let m = matcher();
268 let signals = vec![
270 "expected type `u32`".to_string(),
271 "found type `String` — type mismatch".to_string(),
272 ];
273 let cls = m.classify(&signals).expect("should classify");
274 assert_eq!(cls.id, "type-mismatch");
275 }
276
277 #[test]
280 fn test_no_false_positive_type_vs_borrow() {
281 let m = matcher();
282 let signals = vec!["error[E0308]: mismatched type".to_string()];
284 let cls = m.classify(&signals).unwrap();
285 assert_ne!(
286 cls.id, "borrow-conflict",
287 "must not cross-match borrow-conflict"
288 );
289 }
290
291 #[test]
292 fn test_no_false_positive_borrow_vs_import() {
293 let m = matcher();
294 let signals = vec!["error[E0502]: cannot borrow as mutable".to_string()];
295 let cls = m.classify(&signals).unwrap();
296 assert_ne!(cls.id, "missing-import");
297 }
298
299 #[test]
300 fn test_no_match_returns_none() {
301 let m = matcher();
302 let signals = vec!["network timeout connecting to database server".to_string()];
304 if let Some(cls) = m.classify(&signals) {
307 assert_ne!(cls.id, "missing-import");
308 assert_ne!(cls.id, "type-mismatch");
309 assert_ne!(cls.id, "borrow-conflict");
310 }
311 }
313
314 #[test]
315 fn test_empty_signals_returns_none() {
316 let m = matcher();
317 assert!(m.classify(&[]).is_none());
318 }
319
320 #[test]
323 fn test_custom_class_wins_over_builtin() {
324 let mut classes = builtin_task_classes();
326 classes.push(TaskClass::new(
327 "db-timeout",
328 "Database timeout",
329 ["database", "timeout", "connection", "pool", "exhausted"],
330 ));
331 let m = TaskClassMatcher::new(classes);
332 let signals = vec!["database connection pool exhausted — timeout".to_string()];
333 let cls = m.classify(&signals).expect("should classify");
334 assert_eq!(cls.id, "db-timeout");
335 }
336
337 #[test]
338 fn test_signals_match_class_helper() {
339 let registry = builtin_task_classes();
340 let signals = vec!["error[E0425]: cannot find value".to_string()];
341 assert!(signals_match_class(&signals, "missing-import", ®istry));
342 assert!(!signals_match_class(&signals, "type-mismatch", ®istry));
343 }
344
345 #[test]
346 fn test_overlap_score_case_insensitive() {
347 let class = TaskClass::new("tc", "Test", ["e0425", "unresolved"]);
348 let m = TaskClassMatcher::new(vec![class]);
349 let signals = vec!["E0425 unresolved import".to_string()];
352 let cls = m
353 .classify(&signals)
354 .expect("case-insensitive classify should work");
355 assert_eq!(cls.id, "tc");
356 }
357}