1use crate::signal::ExtractedSignal;
4use crate::source::IntakeEvent;
5use regex_lite::Regex;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Clone, Debug, Serialize, Deserialize)]
11pub struct IntakeRule {
12 pub id: String,
14 pub name: String,
16 pub description: String,
18 pub priority: i32,
20 pub enabled: bool,
22 pub conditions: RuleConditions,
24 pub actions: Vec<RuleAction>,
26}
27
28#[derive(Clone, Debug, Serialize, Deserialize, Default)]
30pub struct RuleConditions {
31 #[serde(default)]
33 pub source_types: Vec<String>,
34 #[serde(default)]
36 pub severities: Vec<String>,
37 #[serde(default)]
39 pub title_pattern: Option<String>,
40 #[serde(default)]
42 pub description_pattern: Option<String>,
43 #[serde(default)]
45 pub signal_patterns: Vec<String>,
46 #[serde(default)]
48 pub min_confidence: Option<f32>,
49}
50
51#[derive(Clone, Debug, Serialize, Deserialize)]
53#[serde(tag = "type", rename_all = "snake_case")]
54pub enum RuleAction {
55 SetSeverity { severity: String },
57 AddTags { tags: Vec<String> },
59 SetPriorityBoost { boost: i32 },
61 RouteToQueue { queue: String },
63 Skip,
65 RequireApproval,
67 AddSignals { signals: Vec<String> },
69 SetTarget { target: String },
71}
72
73#[derive(Clone, Debug)]
75pub struct RuleApplication {
76 pub rule_id: String,
77 pub matched: bool,
78 pub actions_applied: Vec<RuleAction>,
79 pub modified_event: Option<IntakeEvent>,
80 pub should_skip: bool,
81}
82
83#[derive(Clone, Debug)]
85pub struct RuleProcessingResult {
86 pub event: IntakeEvent,
87 pub applications: Vec<RuleApplication>,
88 pub should_skip: bool,
89}
90
91pub struct RuleEngine {
93 rules: Vec<IntakeRule>,
94 compiled_patterns: HashMap<String, Regex>,
95}
96
97impl RuleEngine {
98 pub fn new() -> Self {
100 let mut engine = Self {
101 rules: Vec::new(),
102 compiled_patterns: HashMap::new(),
103 };
104
105 engine.add_default_rules();
107 engine
108 }
109
110 pub fn with_rules(rules: Vec<IntakeRule>) -> Self {
112 let mut engine = Self {
113 rules,
114 compiled_patterns: HashMap::new(),
115 };
116 engine.compile_patterns();
117 engine
118 }
119
120 fn add_default_rules(&mut self) {
122 self.rules = vec![
123 IntakeRule {
124 id: "rule_critical_security".to_string(),
125 name: "Critical Security Issues".to_string(),
126 description: "Route critical security issues for immediate attention".to_string(),
127 priority: 100,
128 enabled: true,
129 conditions: RuleConditions {
130 severities: vec!["critical".to_string()],
131 signal_patterns: vec!["security".to_string(), "vulnerability".to_string()],
132 ..Default::default()
133 },
134 actions: vec![
135 RuleAction::RequireApproval,
136 RuleAction::SetPriorityBoost { boost: 50 },
137 ],
138 },
139 IntakeRule {
140 id: "rule_compiler_errors".to_string(),
141 name: "Compiler Errors".to_string(),
142 description: "High priority for compiler errors".to_string(),
143 priority: 80,
144 enabled: true,
145 conditions: RuleConditions {
146 signal_patterns: vec!["compiler_error".to_string()],
147 min_confidence: Some(0.7),
148 ..Default::default()
149 },
150 actions: vec![RuleAction::SetPriorityBoost { boost: 30 }],
151 },
152 IntakeRule {
153 id: "rule_test_failures".to_string(),
154 name: "Test Failures".to_string(),
155 description: "Handle test failures from CI".to_string(),
156 priority: 60,
157 enabled: true,
158 conditions: RuleConditions {
159 source_types: vec!["github".to_string(), "gitlab".to_string()],
160 signal_patterns: vec!["test_failure".to_string()],
161 ..Default::default()
162 },
163 actions: vec![RuleAction::AddTags {
164 tags: vec!["ci".to_string(), "test".to_string()],
165 }],
166 },
167 IntakeRule {
168 id: "rule_low_confidence".to_string(),
169 name: "Low Confidence Events".to_string(),
170 description: "Require approval for low confidence events".to_string(),
171 priority: 10,
172 enabled: true,
173 conditions: RuleConditions {
174 min_confidence: Some(0.3),
175 ..Default::default()
176 },
177 actions: vec![RuleAction::RequireApproval],
178 },
179 ];
180
181 self.compile_patterns();
182 }
183
184 fn compile_patterns(&mut self) {
186 self.compiled_patterns.clear();
187
188 for rule in &self.rules {
189 if let Some(ref pattern) = rule.conditions.title_pattern {
190 if let Ok(re) = Regex::new(pattern) {
191 self.compiled_patterns
192 .insert(format!("{}:title", rule.id), re);
193 }
194 }
195 if let Some(ref pattern) = rule.conditions.description_pattern {
196 if let Ok(re) = Regex::new(pattern) {
197 self.compiled_patterns
198 .insert(format!("{}:desc", rule.id), re);
199 }
200 }
201 }
202 }
203
204 pub fn evaluate(
206 &self,
207 event: &IntakeEvent,
208 signals: &[ExtractedSignal],
209 ) -> Vec<RuleApplication> {
210 let mut results = Vec::new();
211
212 let mut sorted_rules = self.rules.clone();
214 sorted_rules.sort_by(|a, b| b.priority.cmp(&a.priority));
215
216 for rule in sorted_rules {
217 if !rule.enabled {
218 continue;
219 }
220
221 let matched = self.matches_conditions(&rule.conditions, event, signals);
222
223 if matched {
224 let application = RuleApplication {
225 rule_id: rule.id.clone(),
226 matched: true,
227 actions_applied: rule.actions.clone(),
228 modified_event: None,
229 should_skip: rule.actions.iter().any(|a| matches!(a, RuleAction::Skip)),
230 };
231 results.push(application);
232 }
233 }
234
235 results
236 }
237
238 pub fn apply(&self, event: &IntakeEvent, signals: &[ExtractedSignal]) -> RuleProcessingResult {
240 let applications = self.evaluate(event, signals);
241 let mut modified_event = event.clone();
242 let mut should_skip = false;
243
244 for application in &applications {
245 should_skip |= application.should_skip;
246
247 for action in &application.actions_applied {
248 match action {
249 RuleAction::SetSeverity { severity } => {
250 if let Some(mapped) = parse_severity(severity) {
251 modified_event.severity = mapped;
252 }
253 }
254 RuleAction::AddSignals { signals } => {
255 for signal in signals {
256 if !modified_event.signals.contains(signal) {
257 modified_event.signals.push(signal.clone());
258 }
259 }
260 }
261 RuleAction::Skip => {
262 should_skip = true;
263 }
264 _ => {}
265 }
266 }
267 }
268
269 RuleProcessingResult {
270 event: modified_event,
271 applications,
272 should_skip,
273 }
274 }
275
276 fn matches_conditions(
278 &self,
279 conditions: &RuleConditions,
280 event: &IntakeEvent,
281 signals: &[ExtractedSignal],
282 ) -> bool {
283 if !conditions.source_types.is_empty() {
285 let source_match = conditions
286 .source_types
287 .iter()
288 .any(|st| st.eq_ignore_ascii_case(&event.source_type.to_string()));
289 if !source_match {
290 return false;
291 }
292 }
293
294 if !conditions.severities.is_empty() {
296 let severity_match = conditions
297 .severities
298 .iter()
299 .any(|s| s.eq_ignore_ascii_case(&event.severity.to_string()));
300 if !severity_match {
301 return false;
302 }
303 }
304
305 if let Some(ref pattern) = conditions.title_pattern {
307 let key = format!("{}:title", "");
308 if let Some(re) = self.compiled_patterns.get(&key) {
309 if !re.is_match(&event.title) {
310 return false;
311 }
312 }
313 }
314
315 if let Some(ref pattern) = conditions.description_pattern {
317 let key = format!("{}:desc", "");
318 if let Some(re) = self.compiled_patterns.get(&key) {
319 if !re.is_match(&event.description) {
320 return false;
321 }
322 }
323 }
324
325 if !conditions.signal_patterns.is_empty() {
327 let signal_match = signals.iter().any(|s| {
328 conditions
329 .signal_patterns
330 .iter()
331 .any(|p| s.content.to_lowercase().contains(&p.to_lowercase()))
332 });
333 if !signal_match {
334 return false;
335 }
336 }
337
338 if let Some(min_conf) = conditions.min_confidence {
340 let avg_conf: f32 = if signals.is_empty() {
341 0.0
342 } else {
343 signals.iter().map(|s| s.confidence).sum::<f32>() / signals.len() as f32
344 };
345 if avg_conf < min_conf {
346 return false;
347 }
348 }
349
350 true
351 }
352
353 pub fn add_rule(&mut self, rule: IntakeRule) {
355 self.rules.push(rule);
356 self.compile_patterns();
357 }
358
359 pub fn get_rules(&self) -> &[IntakeRule] {
361 &self.rules
362 }
363}
364
365impl Default for RuleEngine {
366 fn default() -> Self {
367 Self::new()
368 }
369}
370
371fn parse_severity(value: &str) -> Option<crate::source::IssueSeverity> {
372 match value.to_ascii_lowercase().as_str() {
373 "critical" => Some(crate::source::IssueSeverity::Critical),
374 "high" => Some(crate::source::IssueSeverity::High),
375 "medium" => Some(crate::source::IssueSeverity::Medium),
376 "low" => Some(crate::source::IssueSeverity::Low),
377 "info" => Some(crate::source::IssueSeverity::Info),
378 _ => None,
379 }
380}
381
382pub struct ProblemClassifier {
384 patterns: HashMap<String, Vec<(String, f32)>>,
386}
387
388impl ProblemClassifier {
389 pub fn new() -> Self {
391 let mut patterns = HashMap::new();
392
393 patterns.insert(
395 "compiler_error".to_string(),
396 vec![
397 ("borrow".to_string(), 0.9),
398 ("type mismatch".to_string(), 0.85),
399 ("cannot find".to_string(), 0.8),
400 ("unresolved".to_string(), 0.8),
401 ("compile".to_string(), 0.7),
402 ],
403 );
404
405 patterns.insert(
407 "runtime_error".to_string(),
408 vec![
409 ("panic".to_string(), 0.95),
410 ("timeout".to_string(), 0.8),
411 ("connection".to_string(), 0.75),
412 ("null".to_string(), 0.7),
413 ("exception".to_string(), 0.7),
414 ],
415 );
416
417 patterns.insert(
419 "test_failure".to_string(),
420 vec![
421 ("test failed".to_string(), 0.95),
422 ("assertion".to_string(), 0.85),
423 ("expected".to_string(), 0.7),
424 ("mock".to_string(), 0.6),
425 ],
426 );
427
428 patterns.insert(
430 "performance".to_string(),
431 vec![
432 ("slow".to_string(), 0.85),
433 ("latency".to_string(), 0.8),
434 ("memory leak".to_string(), 0.9),
435 ("cpu".to_string(), 0.7),
436 ("throughput".to_string(), 0.7),
437 ],
438 );
439
440 patterns.insert(
442 "security".to_string(),
443 vec![
444 ("vulnerability".to_string(), 0.95),
445 ("security".to_string(), 0.9),
446 ("injection".to_string(), 0.85),
447 ("xss".to_string(), 0.9),
448 ("sql injection".to_string(), 0.9),
449 ],
450 );
451
452 patterns.insert(
454 "configuration".to_string(),
455 vec![
456 ("config".to_string(), 0.9),
457 ("missing".to_string(), 0.7),
458 ("permission".to_string(), 0.75),
459 ("denied".to_string(), 0.7),
460 ],
461 );
462
463 Self { patterns }
464 }
465
466 pub fn classify(&self, event: &IntakeEvent) -> Vec<ProblemCategory> {
468 let text = format!("{} {}", event.title, event.description).to_lowercase();
469 let mut scores = Vec::new();
470
471 for (category, patterns) in &self.patterns {
472 let mut score = 0.0;
473 for (pattern, weight) in patterns {
474 if text.contains(&pattern.to_lowercase()) {
475 score += weight;
476 }
477 }
478 if score > 0.0 {
479 scores.push(ProblemCategory {
480 category: category.clone(),
481 confidence: (score / patterns.len() as f32).min(1.0),
482 });
483 }
484 }
485
486 scores.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap());
488
489 scores
490 }
491}
492
493impl Default for ProblemClassifier {
494 fn default() -> Self {
495 Self::new()
496 }
497}
498
499#[derive(Clone, Debug)]
501pub struct ProblemCategory {
502 pub category: String,
503 pub confidence: f32,
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::source::{IntakeSourceType, IssueSeverity};
510
511 #[test]
512 fn test_rule_engine_default_rules() {
513 let engine = RuleEngine::new();
514 assert!(!engine.get_rules().is_empty());
515 }
516
517 #[test]
518 fn test_rule_matching() {
519 let engine = RuleEngine::new();
520
521 let event = IntakeEvent {
522 event_id: "test-1".to_string(),
523 source_type: IntakeSourceType::Github,
524 source_event_id: None,
525 title: "Build failed".to_string(),
526 description: "Borrow checker error".to_string(),
527 severity: IssueSeverity::High,
528 signals: vec![],
529 raw_payload: None,
530 timestamp_ms: 0,
531 };
532
533 let signals = vec![ExtractedSignal {
534 signal_id: "sig-1".to_string(),
535 content: "compiler_error:borrow checker".to_string(),
536 signal_type: crate::signal::SignalType::CompilerError,
537 confidence: 0.8,
538 source: "test".to_string(),
539 }];
540
541 let results = engine.evaluate(&event, &signals);
542 assert!(!results.is_empty());
543 }
544
545 #[test]
546 fn test_problem_classifier() {
547 let classifier = ProblemClassifier::new();
548
549 let event = IntakeEvent {
550 event_id: "test-1".to_string(),
551 source_type: IntakeSourceType::Github,
552 source_event_id: None,
553 title: "SQL Injection vulnerability found".to_string(),
554 description: "Security issue in login".to_string(),
555 severity: IssueSeverity::Critical,
556 signals: vec![],
557 raw_payload: None,
558 timestamp_ms: 0,
559 };
560
561 let categories = classifier.classify(&event);
562 assert!(!categories.is_empty());
563 assert_eq!(categories[0].category, "security");
564 }
565
566 #[test]
567 fn test_custom_rule() {
568 let mut engine = RuleEngine::new();
569
570 let rule = IntakeRule {
571 id: "custom_rule".to_string(),
572 name: "Custom Rule".to_string(),
573 description: "Test custom rule".to_string(),
574 priority: 50,
575 enabled: true,
576 conditions: RuleConditions {
577 severities: vec!["critical".to_string()],
578 ..Default::default()
579 },
580 actions: vec![RuleAction::Skip],
581 };
582
583 engine.add_rule(rule);
584 assert!(engine.get_rules().len() > 4);
585 }
586
587 #[test]
588 fn test_apply_returns_skip_when_matching_rule_requests_it() {
589 let engine = RuleEngine::with_rules(vec![IntakeRule {
590 id: "skip_high".to_string(),
591 name: "Skip high severity".to_string(),
592 description: "skip event".to_string(),
593 priority: 100,
594 enabled: true,
595 conditions: RuleConditions {
596 severities: vec!["high".to_string()],
597 ..Default::default()
598 },
599 actions: vec![RuleAction::Skip],
600 }]);
601
602 let event = IntakeEvent {
603 event_id: "evt-1".to_string(),
604 source_type: IntakeSourceType::Github,
605 source_event_id: None,
606 title: "Build failed".to_string(),
607 description: "compiler broke".to_string(),
608 severity: IssueSeverity::High,
609 signals: vec![],
610 raw_payload: None,
611 timestamp_ms: 0,
612 };
613
614 let result = engine.apply(&event, &[]);
615 assert!(result.should_skip);
616 assert_eq!(result.applications.len(), 1);
617 assert_eq!(result.applications[0].rule_id, "skip_high");
618 }
619}