use std::collections::HashMap;
use std::path::PathBuf;
use crate::config::EscalationConfig;
use crate::finding::{Finding, ScanResult};
use crate::rule::{RuleId, Severity};
use crate::scanner::count_severities;
fn threshold_for(config: &EscalationConfig, rule_id: &str) -> usize {
let configured = config.rules.get(rule_id).copied();
configured.unwrap_or(config.threshold).max(1)
}
pub fn escalate(findings: &mut [Finding], config: &EscalationConfig) -> usize {
let mut counts: HashMap<(PathBuf, RuleId), usize> = HashMap::new();
for f in findings.iter() {
let key = (f.file.clone(), f.rule_id.clone());
*counts.entry(key).or_insert(0) += 1;
}
let mut escalated = 0;
for f in findings.iter_mut() {
if f.severity != Severity::Warning {
continue;
}
let key = (f.file.clone(), f.rule_id.clone());
let count = counts.get(&key).copied().unwrap_or(0);
if count >= threshold_for(config, f.rule_id.as_str()) {
f.severity = Severity::Error;
f.escalated = true;
escalated += 1;
}
}
escalated
}
pub fn apply_escalation(result: &mut ScanResult, config: &EscalationConfig) -> usize {
if !config.enabled {
return 0;
}
let escalated = escalate(&mut result.findings, config);
if escalated > 0 {
let (errors, warnings) = count_severities(&result.findings);
result.stats.errors = errors;
result.stats.warnings = warnings;
result.stats.total = errors + warnings;
}
escalated
}
#[cfg(test)]
mod tests {
use crate::finding::ScanStats;
use crate::rule::Category;
use super::*;
fn finding(rule_id: &str, severity: Severity, file: &str) -> Finding {
Finding {
rule_id: rule_id.into(),
severity,
category: Category::Correctness,
message: "m".to_string(),
note: None,
fix: None,
file: PathBuf::from(file),
line: 1,
column: 1,
end_line: 1,
end_column: 2,
matched_text: "x".to_string(),
confidence: None,
escalated: false,
}
}
fn config(enabled: bool, threshold: usize, rules: &[(&str, usize)]) -> EscalationConfig {
let mut overrides = HashMap::new();
for (id, n) in rules {
overrides.insert((*id).to_string(), *n);
}
EscalationConfig {
enabled,
threshold,
rules: overrides,
}
}
fn repeated(rule_id: &str, severity: Severity, file: &str, n: usize) -> Vec<Finding> {
let mut findings = Vec::with_capacity(n);
for _ in 0..n {
findings.push(finding(rule_id, severity.clone(), file));
}
findings
}
fn result_of(findings: Vec<Finding>) -> ScanResult {
let (errors, warnings) = count_severities(&findings);
ScanResult {
findings,
stats: ScanStats {
errors,
warnings,
total: errors + warnings,
files_scanned: 3,
baseline_filtered: 0,
diff_base: None,
files_changed: None,
},
cache_stats: None,
}
}
#[test]
fn below_threshold_is_untouched() {
let mut findings = repeated("a", Severity::Warning, "a.rs", 4);
assert_eq!(escalate(&mut findings, &config(true, 5, &[])), 0);
assert!(findings.iter().all(|f| f.severity == Severity::Warning));
assert!(findings.iter().all(|f| !f.escalated));
}
#[test]
fn at_threshold_escalates_every_finding_of_the_rule() {
let mut findings = repeated("a", Severity::Warning, "a.rs", 5);
assert_eq!(escalate(&mut findings, &config(true, 5, &[])), 5);
assert!(findings.iter().all(|f| f.severity == Severity::Error));
assert!(findings.iter().all(|f| f.escalated));
}
#[test]
fn counting_is_per_file() {
let mut findings = repeated("a", Severity::Warning, "a.rs", 3);
findings.extend(repeated("a", Severity::Warning, "b.rs", 3));
assert_eq!(escalate(&mut findings, &config(true, 5, &[])), 0);
}
#[test]
fn counting_is_per_rule() {
let mut findings = repeated("a", Severity::Warning, "a.rs", 3);
findings.extend(repeated("b", Severity::Warning, "a.rs", 3));
assert_eq!(escalate(&mut findings, &config(true, 5, &[])), 0);
}
#[test]
fn errors_are_not_escalated_further() {
let mut findings = repeated("a", Severity::Error, "a.rs", 5);
assert_eq!(escalate(&mut findings, &config(true, 5, &[])), 0);
assert!(findings.iter().all(|f| f.severity == Severity::Error));
assert!(findings.iter().all(|f| !f.escalated));
}
#[test]
fn per_rule_override_lowers_threshold() {
let mut findings = repeated("a", Severity::Warning, "a.rs", 3);
assert_eq!(escalate(&mut findings, &config(true, 5, &[("a", 3)])), 3);
assert!(findings.iter().all(|f| f.severity == Severity::Error));
}
#[test]
fn per_rule_override_raises_threshold() {
let mut findings = repeated("a", Severity::Warning, "a.rs", 5);
assert_eq!(escalate(&mut findings, &config(true, 5, &[("a", 10)])), 0);
assert!(findings.iter().all(|f| f.severity == Severity::Warning));
}
#[test]
fn zero_threshold_behaves_as_one() {
let mut findings = repeated("a", Severity::Warning, "a.rs", 1);
assert_eq!(escalate(&mut findings, &config(true, 0, &[])), 1);
assert_eq!(findings[0].severity, Severity::Error);
let mut empty: Vec<Finding> = Vec::new();
assert_eq!(escalate(&mut empty, &config(true, 0, &[])), 0);
}
#[test]
fn disabled_config_is_a_no_op() {
let mut result = result_of(repeated("a", Severity::Warning, "a.rs", 5));
assert_eq!(apply_escalation(&mut result, &config(false, 5, &[])), 0);
assert_eq!(result.stats.errors, 0);
assert_eq!(result.stats.warnings, 5);
assert!(result.findings.iter().all(|f| !f.escalated));
}
#[test]
fn apply_escalation_recomputes_stats() {
let mut result = result_of(repeated("a", Severity::Warning, "a.rs", 5));
assert_eq!(apply_escalation(&mut result, &config(true, 5, &[])), 5);
assert_eq!(result.stats.errors, 5);
assert_eq!(result.stats.warnings, 0);
assert_eq!(result.stats.total, 5);
assert_eq!(result.stats.files_scanned, 3);
}
}