use crate::config::{ConfigOrigin, Severity};
use crate::decide::MatchBy;
#[derive(Debug, Clone)]
pub struct FileOutcome {
pub path: std::path::PathBuf,
pub display_path: String,
pub config_source: ConfigOrigin,
pub kind: OutcomeKind,
}
#[derive(Debug, Clone)]
pub enum OutcomeKind {
Excluded {
pattern: String,
},
Exempt {
pattern: String,
},
NoLimit,
Missing,
Unreadable {
error: String,
},
Binary,
Violation {
limit: usize,
actual: usize,
severity: Severity,
matched_by: MatchBy,
},
Pass {
limit: usize,
actual: usize,
severity: Severity,
matched_by: MatchBy,
},
}
#[derive(Debug, Clone)]
pub enum SkipReason {
Binary,
Unreadable(String),
Missing,
}
#[derive(Debug, Clone)]
pub enum FindingKind {
Violation {
severity: Severity,
limit: usize,
actual: usize,
over_by: usize,
matched_by: MatchBy,
},
SkipWarning {
reason: SkipReason,
},
}
#[derive(Debug, Clone)]
pub struct Finding {
pub path: String,
pub config_source: ConfigOrigin,
pub kind: FindingKind,
}
#[derive(Debug, Clone, Default)]
pub struct Summary {
pub total: usize,
pub skipped: usize,
pub passed: usize,
pub errors: usize,
pub warnings: usize,
pub duration_ms: u128,
}
#[derive(Debug, Clone)]
pub struct Report {
pub findings: Vec<Finding>,
pub summary: Summary,
}
#[must_use]
pub fn build_report(outcomes: &[FileOutcome], duration_ms: u128) -> Report {
let mut findings = Vec::new();
let mut summary = Summary {
total: outcomes.len(),
duration_ms,
..Summary::default()
};
for outcome in outcomes {
match &outcome.kind {
OutcomeKind::Excluded { .. } | OutcomeKind::Exempt { .. } | OutcomeKind::NoLimit => {
summary.skipped += 1;
}
OutcomeKind::Missing => {
summary.skipped += 1;
findings.push(Finding {
path: outcome.display_path.clone(),
config_source: outcome.config_source.clone(),
kind: FindingKind::SkipWarning {
reason: SkipReason::Missing,
},
});
}
OutcomeKind::Unreadable { error } => {
summary.skipped += 1;
findings.push(Finding {
path: outcome.display_path.clone(),
config_source: outcome.config_source.clone(),
kind: FindingKind::SkipWarning {
reason: SkipReason::Unreadable(error.clone()),
},
});
}
OutcomeKind::Binary => {
summary.skipped += 1;
findings.push(Finding {
path: outcome.display_path.clone(),
config_source: outcome.config_source.clone(),
kind: FindingKind::SkipWarning {
reason: SkipReason::Binary,
},
});
}
OutcomeKind::Pass { .. } => {
summary.passed += 1;
}
OutcomeKind::Violation {
severity,
limit,
actual,
matched_by,
} => {
let over_by = actual.saturating_sub(*limit);
findings.push(Finding {
path: outcome.display_path.clone(),
config_source: outcome.config_source.clone(),
kind: FindingKind::Violation {
severity: *severity,
limit: *limit,
actual: *actual,
over_by,
matched_by: matched_by.clone(),
},
});
match severity {
Severity::Error => summary.errors += 1,
Severity::Warning => summary.warnings += 1,
}
}
}
}
sort_findings(&mut findings);
Report { findings, summary }
}
pub fn sort_findings(findings: &mut [Finding]) {
findings.sort_by(|a, b| {
let rank_a = finding_rank(&a.kind);
let rank_b = finding_rank(&b.kind);
if rank_a != rank_b {
return rank_a.cmp(&rank_b);
}
match (&a.kind, &b.kind) {
(
FindingKind::Violation {
over_by: a_over, ..
},
FindingKind::Violation {
over_by: b_over, ..
},
) => a_over.cmp(b_over).then_with(|| a.path.cmp(&b.path)),
_ => a.path.cmp(&b.path),
}
});
}
const fn finding_rank(kind: &FindingKind) -> u8 {
match kind {
FindingKind::SkipWarning { .. } => 0,
FindingKind::Violation { severity, .. } => match severity {
Severity::Warning => 1,
Severity::Error => 2,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConfigOrigin;
#[test]
fn summary_counts_each_file_once() {
let outcomes = vec![
FileOutcome {
path: "a".into(),
display_path: "a".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::Pass {
limit: 10,
actual: 5,
severity: Severity::Error,
matched_by: MatchBy::Default,
},
},
FileOutcome {
path: "b".into(),
display_path: "b".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::Violation {
limit: 10,
actual: 20,
severity: Severity::Error,
matched_by: MatchBy::Default,
},
},
FileOutcome {
path: "c".into(),
display_path: "c".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::Violation {
limit: 10,
actual: 12,
severity: Severity::Warning,
matched_by: MatchBy::Default,
},
},
FileOutcome {
path: "d".into(),
display_path: "d".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::Missing,
},
FileOutcome {
path: "e".into(),
display_path: "e".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::Binary,
},
FileOutcome {
path: "f".into(),
display_path: "f".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::Unreadable {
error: "denied".into(),
},
},
];
let report = build_report(&outcomes, 0);
assert_eq!(report.summary.total, 6);
assert_eq!(report.summary.passed, 1);
assert_eq!(report.summary.errors, 1);
assert_eq!(report.summary.warnings, 1);
assert_eq!(report.summary.skipped, 3);
}
#[test]
fn findings_sorted_by_severity_and_overage() {
let mut findings = vec![
Finding {
path: "b".into(),
config_source: ConfigOrigin::BuiltIn,
kind: FindingKind::Violation {
severity: Severity::Warning,
limit: 10,
actual: 12,
over_by: 2,
matched_by: MatchBy::Default,
},
},
Finding {
path: "a".into(),
config_source: ConfigOrigin::BuiltIn,
kind: FindingKind::Violation {
severity: Severity::Error,
limit: 10,
actual: 20,
over_by: 10,
matched_by: MatchBy::Default,
},
},
Finding {
path: "c".into(),
config_source: ConfigOrigin::BuiltIn,
kind: FindingKind::SkipWarning {
reason: SkipReason::Missing,
},
},
];
sort_findings(&mut findings);
assert_eq!(findings[0].path, "c");
assert_eq!(findings[1].path, "b");
assert_eq!(findings[2].path, "a");
}
#[test]
fn excluded_exempt_nolimit_are_skipped() {
let outcomes = vec![
FileOutcome {
path: "excluded.txt".into(),
display_path: "excluded.txt".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::Excluded {
pattern: "*.txt".to_string(),
},
},
FileOutcome {
path: "exempt.rs".into(),
display_path: "exempt.rs".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::Exempt {
pattern: "exempt.rs".to_string(),
},
},
FileOutcome {
path: "nolimit.js".into(),
display_path: "nolimit.js".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::NoLimit,
},
];
let report = build_report(&outcomes, 0);
assert_eq!(report.summary.total, 3);
assert_eq!(report.summary.skipped, 3);
assert_eq!(report.summary.passed, 0);
assert_eq!(report.summary.errors, 0);
assert_eq!(report.summary.warnings, 0);
assert!(report.findings.is_empty());
}
}