Skip to main content

slopguard_core/
escalation.rs

1//! Severity escalation: a warning that fires once is noise, the same warning
2//! firing many times in one file is a quality problem. When a rule produces at
3//! least `threshold` findings in a single file, its warnings in that file
4//! become errors.
5//!
6//! Applied after baseline filtering and outside the scan cache, so only
7//! findings that are actually reported take part in the count.
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11
12use crate::config::EscalationConfig;
13use crate::finding::{Finding, ScanResult};
14use crate::rule::{RuleId, Severity};
15use crate::scanner::count_severities;
16
17/// The threshold that applies to `rule_id`: the per-rule override when set,
18/// otherwise the global one. A configured 0 behaves as 1, so escalation can
19/// never fire on an empty group.
20fn threshold_for(config: &EscalationConfig, rule_id: &str) -> usize {
21    let configured = config.rules.get(rule_id).copied();
22    configured.unwrap_or(config.threshold).max(1)
23}
24
25/// Escalate warnings to errors in place and return how many were escalated.
26///
27/// Findings are grouped by (file, rule id). Every finding of the group counts
28/// toward the threshold, but only warnings are raised: escalation is one level
29/// only, and an error stays an error.
30pub fn escalate(findings: &mut [Finding], config: &EscalationConfig) -> usize {
31    let mut counts: HashMap<(PathBuf, RuleId), usize> = HashMap::new();
32    for f in findings.iter() {
33        let key = (f.file.clone(), f.rule_id.clone());
34        *counts.entry(key).or_insert(0) += 1;
35    }
36
37    let mut escalated = 0;
38    for f in findings.iter_mut() {
39        if f.severity != Severity::Warning {
40            continue;
41        }
42        let key = (f.file.clone(), f.rule_id.clone());
43        let count = counts.get(&key).copied().unwrap_or(0);
44        if count >= threshold_for(config, f.rule_id.as_str()) {
45            f.severity = Severity::Error;
46            f.escalated = true;
47            escalated += 1;
48        }
49    }
50    escalated
51}
52
53/// Apply escalation to a scan result and keep `stats` consistent.
54///
55/// A no-op when `config.enabled` is false. Returns the number of escalated
56/// findings. `files_scanned` and `baseline_filtered` are preserved.
57pub fn apply_escalation(result: &mut ScanResult, config: &EscalationConfig) -> usize {
58    if !config.enabled {
59        return 0;
60    }
61    let escalated = escalate(&mut result.findings, config);
62    if escalated > 0 {
63        let (errors, warnings) = count_severities(&result.findings);
64        result.stats.errors = errors;
65        result.stats.warnings = warnings;
66        result.stats.total = errors + warnings;
67    }
68    escalated
69}
70
71#[cfg(test)]
72mod tests {
73    use crate::finding::ScanStats;
74    use crate::rule::Category;
75
76    use super::*;
77
78    fn finding(rule_id: &str, severity: Severity, file: &str) -> Finding {
79        Finding {
80            rule_id: rule_id.into(),
81            severity,
82            category: Category::Correctness,
83            message: "m".to_string(),
84            note: None,
85            fix: None,
86            file: PathBuf::from(file),
87            line: 1,
88            column: 1,
89            end_line: 1,
90            end_column: 2,
91            matched_text: "x".to_string(),
92            confidence: None,
93            escalated: false,
94        }
95    }
96
97    fn config(enabled: bool, threshold: usize, rules: &[(&str, usize)]) -> EscalationConfig {
98        let mut overrides = HashMap::new();
99        for (id, n) in rules {
100            overrides.insert((*id).to_string(), *n);
101        }
102        EscalationConfig {
103            enabled,
104            threshold,
105            rules: overrides,
106        }
107    }
108
109    fn repeated(rule_id: &str, severity: Severity, file: &str, n: usize) -> Vec<Finding> {
110        let mut findings = Vec::with_capacity(n);
111        for _ in 0..n {
112            findings.push(finding(rule_id, severity.clone(), file));
113        }
114        findings
115    }
116
117    fn result_of(findings: Vec<Finding>) -> ScanResult {
118        let (errors, warnings) = count_severities(&findings);
119        ScanResult {
120            findings,
121            stats: ScanStats {
122                errors,
123                warnings,
124                total: errors + warnings,
125                files_scanned: 3,
126                baseline_filtered: 0,
127                diff_base: None,
128                files_changed: None,
129            },
130            cache_stats: None,
131        }
132    }
133
134    #[test]
135    fn below_threshold_is_untouched() {
136        let mut findings = repeated("a", Severity::Warning, "a.rs", 4);
137
138        assert_eq!(escalate(&mut findings, &config(true, 5, &[])), 0);
139        assert!(findings.iter().all(|f| f.severity == Severity::Warning));
140        assert!(findings.iter().all(|f| !f.escalated));
141    }
142
143    #[test]
144    fn at_threshold_escalates_every_finding_of_the_rule() {
145        let mut findings = repeated("a", Severity::Warning, "a.rs", 5);
146
147        assert_eq!(escalate(&mut findings, &config(true, 5, &[])), 5);
148        assert!(findings.iter().all(|f| f.severity == Severity::Error));
149        assert!(findings.iter().all(|f| f.escalated));
150    }
151
152    #[test]
153    fn counting_is_per_file() {
154        let mut findings = repeated("a", Severity::Warning, "a.rs", 3);
155        findings.extend(repeated("a", Severity::Warning, "b.rs", 3));
156
157        assert_eq!(escalate(&mut findings, &config(true, 5, &[])), 0);
158    }
159
160    #[test]
161    fn counting_is_per_rule() {
162        let mut findings = repeated("a", Severity::Warning, "a.rs", 3);
163        findings.extend(repeated("b", Severity::Warning, "a.rs", 3));
164
165        assert_eq!(escalate(&mut findings, &config(true, 5, &[])), 0);
166    }
167
168    #[test]
169    fn errors_are_not_escalated_further() {
170        let mut findings = repeated("a", Severity::Error, "a.rs", 5);
171
172        assert_eq!(escalate(&mut findings, &config(true, 5, &[])), 0);
173        assert!(findings.iter().all(|f| f.severity == Severity::Error));
174        assert!(findings.iter().all(|f| !f.escalated));
175    }
176
177    #[test]
178    fn per_rule_override_lowers_threshold() {
179        let mut findings = repeated("a", Severity::Warning, "a.rs", 3);
180
181        assert_eq!(escalate(&mut findings, &config(true, 5, &[("a", 3)])), 3);
182        assert!(findings.iter().all(|f| f.severity == Severity::Error));
183    }
184
185    #[test]
186    fn per_rule_override_raises_threshold() {
187        let mut findings = repeated("a", Severity::Warning, "a.rs", 5);
188
189        assert_eq!(escalate(&mut findings, &config(true, 5, &[("a", 10)])), 0);
190        assert!(findings.iter().all(|f| f.severity == Severity::Warning));
191    }
192
193    #[test]
194    fn zero_threshold_behaves_as_one() {
195        let mut findings = repeated("a", Severity::Warning, "a.rs", 1);
196
197        assert_eq!(escalate(&mut findings, &config(true, 0, &[])), 1);
198        assert_eq!(findings[0].severity, Severity::Error);
199
200        let mut empty: Vec<Finding> = Vec::new();
201        assert_eq!(escalate(&mut empty, &config(true, 0, &[])), 0);
202    }
203
204    #[test]
205    fn disabled_config_is_a_no_op() {
206        let mut result = result_of(repeated("a", Severity::Warning, "a.rs", 5));
207
208        assert_eq!(apply_escalation(&mut result, &config(false, 5, &[])), 0);
209        assert_eq!(result.stats.errors, 0);
210        assert_eq!(result.stats.warnings, 5);
211        assert!(result.findings.iter().all(|f| !f.escalated));
212    }
213
214    #[test]
215    fn apply_escalation_recomputes_stats() {
216        let mut result = result_of(repeated("a", Severity::Warning, "a.rs", 5));
217
218        assert_eq!(apply_escalation(&mut result, &config(true, 5, &[])), 5);
219        assert_eq!(result.stats.errors, 5);
220        assert_eq!(result.stats.warnings, 0);
221        assert_eq!(result.stats.total, 5);
222        assert_eq!(result.stats.files_scanned, 3);
223    }
224}