Skip to main content

oris_intake/
rules.rs

1//! Custom rule engine for intake processing
2
3use crate::signal::ExtractedSignal;
4use crate::source::IntakeEvent;
5use regex_lite::Regex;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// A rule for processing intake events
10#[derive(Clone, Debug, Serialize, Deserialize)]
11pub struct IntakeRule {
12    /// Unique rule ID
13    pub id: String,
14    /// Human-readable rule name
15    pub name: String,
16    /// Rule description
17    pub description: String,
18    /// Priority (higher = evaluated first)
19    pub priority: i32,
20    /// Whether the rule is enabled
21    pub enabled: bool,
22    /// Conditions that must match
23    pub conditions: RuleConditions,
24    /// Actions to apply when matched
25    pub actions: Vec<RuleAction>,
26}
27
28/// Conditions for rule matching
29#[derive(Clone, Debug, Serialize, Deserialize, Default)]
30pub struct RuleConditions {
31    /// Match source type
32    #[serde(default)]
33    pub source_types: Vec<String>,
34    /// Match severity (if any match)
35    #[serde(default)]
36    pub severities: Vec<String>,
37    /// Match title pattern (regex)
38    #[serde(default)]
39    pub title_pattern: Option<String>,
40    /// Match description pattern (regex)
41    #[serde(default)]
42    pub description_pattern: Option<String>,
43    /// Match signals containing these patterns
44    #[serde(default)]
45    pub signal_patterns: Vec<String>,
46    /// Minimum confidence threshold
47    #[serde(default)]
48    pub min_confidence: Option<f32>,
49}
50
51/// Actions to perform when rule matches
52#[derive(Clone, Debug, Serialize, Deserialize)]
53#[serde(tag = "type", rename_all = "snake_case")]
54pub enum RuleAction {
55    /// Set custom severity
56    SetSeverity { severity: String },
57    /// Add tags
58    AddTags { tags: Vec<String> },
59    /// Set priority boost
60    SetPriorityBoost { boost: i32 },
61    /// Route to specific queue
62    RouteToQueue { queue: String },
63    /// Skip further processing
64    Skip,
65    /// Require manual approval
66    RequireApproval,
67    /// Add custom signals
68    AddSignals { signals: Vec<String> },
69    /// Set target
70    SetTarget { target: String },
71}
72
73/// Result of applying a rule
74#[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/// Result of applying all matching rules to a single intake event.
84#[derive(Clone, Debug)]
85pub struct RuleProcessingResult {
86    pub event: IntakeEvent,
87    pub applications: Vec<RuleApplication>,
88    pub should_skip: bool,
89}
90
91/// Rule engine for evaluating and applying rules
92pub struct RuleEngine {
93    rules: Vec<IntakeRule>,
94    compiled_patterns: HashMap<String, Regex>,
95}
96
97impl RuleEngine {
98    /// Create a new rule engine with default rules
99    pub fn new() -> Self {
100        let mut engine = Self {
101            rules: Vec::new(),
102            compiled_patterns: HashMap::new(),
103        };
104
105        // Add default rules
106        engine.add_default_rules();
107        engine
108    }
109
110    /// Create from custom rules
111    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    /// Add default rules
121    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    /// Compile regex patterns for efficiency
185    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    /// Evaluate rules against an event
205    pub fn evaluate(
206        &self,
207        event: &IntakeEvent,
208        signals: &[ExtractedSignal],
209    ) -> Vec<RuleApplication> {
210        let mut results = Vec::new();
211
212        // Sort rules by priority (highest first)
213        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    /// Apply matching rules to an event and return the processed result.
239    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    /// Check if conditions match
277    fn matches_conditions(
278        &self,
279        conditions: &RuleConditions,
280        event: &IntakeEvent,
281        signals: &[ExtractedSignal],
282    ) -> bool {
283        // Check source type
284        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        // Check severity
295        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        // Check title pattern
306        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        // Check description pattern
316        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        // Check signal patterns
326        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        // Check min confidence
339        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    /// Add a custom rule
354    pub fn add_rule(&mut self, rule: IntakeRule) {
355        self.rules.push(rule);
356        self.compile_patterns();
357    }
358
359    /// Get all rules
360    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
382/// Simple ML-based classifier for problem categorization
383pub struct ProblemClassifier {
384    /// Known patterns for different problem types
385    patterns: HashMap<String, Vec<(String, f32)>>,
386}
387
388impl ProblemClassifier {
389    /// Create a new classifier
390    pub fn new() -> Self {
391        let mut patterns = HashMap::new();
392
393        // Compiler/Build issues
394        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        // Runtime errors
406        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        // Test failures
418        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        // Performance issues
429        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        // Security issues
441        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        // Configuration issues
453        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    /// Classify an event into problem types
467    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        // Sort by confidence
487        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/// A problem category with confidence
500#[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}