1use 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
17fn 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
25pub 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
53pub 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}