use crate::config::ConfigOrigin;
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 {
NoLimit,
Missing,
Unreadable {
error: String,
},
Binary,
Violation {
limit: usize,
actual: usize,
matched_by: MatchBy,
},
Pass {
limit: usize,
actual: usize,
matched_by: MatchBy,
},
}
#[derive(Debug, Clone)]
pub enum SkipReason {
Binary,
Unreadable(String),
Missing,
}
#[derive(Debug, Clone)]
pub enum FindingKind {
Violation {
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 duration_ms: u128,
}
#[derive(Debug, Clone)]
pub struct Report {
pub findings: Vec<Finding>,
pub summary: Summary,
pub fix_guidance: Option<String>,
}
#[must_use]
pub fn build_report(
outcomes: &[FileOutcome],
duration_ms: u128,
fix_guidance: Option<String>,
) -> 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::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 {
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 {
limit: *limit,
actual: *actual,
over_by,
matched_by: matched_by.clone(),
},
});
summary.errors += 1;
}
}
}
sort_findings(&mut findings);
let fix_guidance = if summary.errors > 0 {
fix_guidance
} else {
None
};
Report {
findings,
summary,
fix_guidance,
}
}
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 { .. } => 1,
}
}
#[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,
matched_by: MatchBy::Default,
},
},
FileOutcome {
path: "b".into(),
display_path: "b".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::Violation {
limit: 10,
actual: 20,
matched_by: MatchBy::Default,
},
},
FileOutcome {
path: "c".into(),
display_path: "c".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::Violation {
limit: 10,
actual: 12,
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, None);
assert_eq!(report.summary.total, 6);
assert_eq!(report.summary.passed, 1);
assert_eq!(report.summary.errors, 2);
assert_eq!(report.summary.skipped, 3);
}
#[test]
fn findings_sorted_by_overage() {
let mut findings = vec![
Finding {
path: "b".into(),
config_source: ConfigOrigin::BuiltIn,
kind: FindingKind::Violation {
limit: 10,
actual: 12,
over_by: 2,
matched_by: MatchBy::Default,
},
},
Finding {
path: "a".into(),
config_source: ConfigOrigin::BuiltIn,
kind: FindingKind::Violation {
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 nolimit_is_skipped() {
let outcomes = vec![FileOutcome {
path: "nolimit.js".into(),
display_path: "nolimit.js".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::NoLimit,
}];
let report = build_report(&outcomes, 0, None);
assert_eq!(report.summary.total, 1);
assert_eq!(report.summary.skipped, 1);
assert_eq!(report.summary.passed, 0);
assert_eq!(report.summary.errors, 0);
assert!(report.findings.is_empty());
}
#[test]
fn fix_guidance_included_when_violations_exist() {
let outcomes = vec![FileOutcome {
path: "big.rs".into(),
display_path: "big.rs".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::Violation {
limit: 100,
actual: 150,
matched_by: MatchBy::Default,
},
}];
let guidance = Some("Split large files into smaller modules.".to_string());
let report = build_report(&outcomes, 0, guidance);
assert_eq!(report.summary.errors, 1);
assert!(report.fix_guidance.is_some());
assert_eq!(
report.fix_guidance.unwrap(),
"Split large files into smaller modules."
);
}
#[test]
fn fix_guidance_excluded_when_no_violations() {
let outcomes = vec![FileOutcome {
path: "small.rs".into(),
display_path: "small.rs".into(),
config_source: ConfigOrigin::BuiltIn,
kind: OutcomeKind::Pass {
limit: 100,
actual: 50,
matched_by: MatchBy::Default,
},
}];
let guidance = Some("Split large files into smaller modules.".to_string());
let report = build_report(&outcomes, 0, guidance);
assert_eq!(report.summary.errors, 0);
assert!(report.fix_guidance.is_none());
}
}