use crate::domain::summary::{FileSummary, compute_file_summaries, compute_summary};
use crate::domain::types::{AnalysisResult, AnalysisSummary, FunctionVerdict};
use serde::Serialize;
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize)]
pub struct ViewSpec {
pub filters: Filters,
pub sort: SortKey,
pub limit: Option<usize>,
pub group_by: Option<GroupKey>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum GroupKey {
File,
}
impl GroupKey {
pub fn as_wire_str(&self) -> &'static str {
match self {
Self::File => "file",
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize)]
pub struct Filters {
pub only_failing: bool,
pub coverage_range: Option<CoverageRange>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Serialize)]
pub struct CoverageRange {
pub min: f64,
pub max: f64,
}
impl CoverageRange {
pub fn new(min: f64, max: f64) -> Result<Self, CoverageRangeError> {
if !is_in_unit_percent(min) {
return Err(CoverageRangeError::OutOfRange { value: min });
}
if !is_in_unit_percent(max) {
return Err(CoverageRangeError::OutOfRange { value: max });
}
if min > max {
return Err(CoverageRangeError::MinExceedsMax { min, max });
}
Ok(Self { min, max })
}
}
fn is_in_unit_percent(v: f64) -> bool {
v.is_finite() && (0.0..=100.0).contains(&v)
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, thiserror::Error, PartialEq)]
pub enum CoverageRangeError {
#[error("coverage value out of range: {value}")]
OutOfRange { value: f64 },
#[error("min ({min}) exceeds max ({max})")]
MinExceedsMax { min: f64, max: f64 },
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SortKey {
#[default]
Crap,
Coverage,
Complexity,
Path,
}
impl SortKey {
pub fn as_wire_str(&self) -> &'static str {
match self {
Self::Crap => "crap",
Self::Coverage => "coverage",
Self::Complexity => "complexity",
Self::Path => "path",
}
}
}
#[non_exhaustive]
#[derive(Debug, Serialize)]
pub struct AnalysisView<'a> {
#[serde(skip)]
pub full: &'a AnalysisResult,
pub spec: ViewSpec,
pub eligible_count: usize,
pub truncated: bool,
pub shown: Vec<&'a FunctionVerdict>,
pub shown_summary: AnalysisSummary,
pub grouped: Option<GroupedView>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize)]
pub struct GroupedView {
pub key: GroupKey,
pub eligible_count: usize,
pub truncated: bool,
pub files: Vec<FileSummary>,
}
pub fn apply<'a>(result: &'a AnalysisResult, spec: ViewSpec) -> AnalysisView<'a> {
let eligible: Vec<&'a FunctionVerdict> = apply_filters(&result.functions, &spec.filters);
let eligible_count = eligible.len();
let grouped = apply_grouping(&eligible, &spec);
let (shown, truncated) = if grouped.is_some() {
(eligible, false)
} else {
let mut shown = eligible;
sort_in_place(&mut shown, spec.sort);
let truncated = truncate_to(&mut shown, spec.limit);
(shown, truncated)
};
let shown_summary = compute_summary(shown.iter().copied());
AnalysisView {
full: result,
spec,
eligible_count,
truncated,
shown,
shown_summary,
grouped,
}
}
fn apply_grouping(eligible: &[&FunctionVerdict], spec: &ViewSpec) -> Option<GroupedView> {
let key = spec.group_by?;
let mut files = compute_file_summaries(eligible.iter().copied());
let eligible_count = files.len();
sort_files_in_place(&mut files, spec.sort);
let truncated = truncate_files_to(&mut files, spec.limit);
Some(GroupedView {
key,
eligible_count,
truncated,
files,
})
}
fn sort_files_in_place(files: &mut [FileSummary], key: SortKey) {
match key {
SortKey::Crap => files.sort_by(cmp_files_by_avg_crap),
SortKey::Coverage => files.sort_by(cmp_files_by_avg_coverage),
SortKey::Complexity => files.sort_by_key(|f| std::cmp::Reverse(f.max_complexity)),
SortKey::Path => files.sort_by(|a, b| a.file_path.cmp(&b.file_path)),
}
}
fn cmp_files_by_avg_crap(a: &FileSummary, b: &FileSummary) -> std::cmp::Ordering {
let (ax, bx) = (a.average_crap, b.average_crap);
match (ax.is_nan(), bx.is_nan()) {
(true, true) => std::cmp::Ordering::Equal,
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => bx.partial_cmp(&ax).expect("non-NaN partial_cmp infallible"),
}
}
fn cmp_files_by_avg_coverage(a: &FileSummary, b: &FileSummary) -> std::cmp::Ordering {
let (ax, bx) = (a.average_coverage, b.average_coverage);
match (ax.is_nan(), bx.is_nan()) {
(true, true) => std::cmp::Ordering::Equal,
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => ax.partial_cmp(&bx).expect("non-NaN partial_cmp infallible"),
}
}
fn truncate_files_to(files: &mut Vec<FileSummary>, limit: Option<usize>) -> bool {
match limit {
Some(n) if n > 0 && files.len() > n => {
files.truncate(n);
true
}
_ => false,
}
}
fn apply_filters<'a>(
verdicts: &'a [FunctionVerdict],
filters: &Filters,
) -> Vec<&'a FunctionVerdict> {
verdicts
.iter()
.filter(|v| !filters.only_failing || v.exceeds)
.filter(|v| match &filters.coverage_range {
Some(range) => matches_coverage_range(v.scored.coverage_percent, range),
None => true,
})
.collect()
}
fn matches_coverage_range(cov: f64, range: &CoverageRange) -> bool {
cov.is_finite() && cov >= range.min && cov <= range.max
}
fn sort_in_place(shown: &mut [&FunctionVerdict], key: SortKey) {
match key {
SortKey::Crap => shown.sort_by(cmp_by_crap),
SortKey::Coverage => shown.sort_by(cmp_by_coverage),
SortKey::Complexity => shown.sort_by(cmp_by_complexity),
SortKey::Path => shown.sort_by(cmp_by_path),
}
}
fn cmp_by_crap(a: &&FunctionVerdict, b: &&FunctionVerdict) -> std::cmp::Ordering {
let (ax, bx) = (a.scored.crap.value, b.scored.crap.value);
match (ax.is_nan(), bx.is_nan()) {
(true, true) => std::cmp::Ordering::Equal,
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => bx.partial_cmp(&ax).expect("non-NaN partial_cmp infallible"),
}
}
fn cmp_by_coverage(a: &&FunctionVerdict, b: &&FunctionVerdict) -> std::cmp::Ordering {
let (ax, bx) = (a.scored.coverage_percent, b.scored.coverage_percent);
match (ax.is_nan(), bx.is_nan()) {
(true, true) => std::cmp::Ordering::Equal,
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => ax.partial_cmp(&bx).expect("non-NaN partial_cmp infallible"),
}
}
fn cmp_by_complexity(a: &&FunctionVerdict, b: &&FunctionVerdict) -> std::cmp::Ordering {
b.scored.complexity.cmp(&a.scored.complexity)
}
fn cmp_by_path(a: &&FunctionVerdict, b: &&FunctionVerdict) -> std::cmp::Ordering {
match a
.scored
.identity
.file_path
.cmp(&b.scored.identity.file_path)
{
std::cmp::Ordering::Equal => cmp_by_crap(a, b),
ord => ord,
}
}
fn truncate_to(shown: &mut Vec<&FunctionVerdict>, limit: Option<usize>) -> bool {
match limit {
Some(n) if n > 0 && shown.len() > n => {
shown.truncate(n);
true
}
_ => false,
}
}
pub fn should_render_view_line(view: &AnalysisView<'_>) -> bool {
view.eligible_count < view.full.functions.len()
|| view.truncated
|| view.grouped.as_ref().is_some_and(|g| g.truncated)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::types::{
AnalysisSummary, ComplexityMetric, CrapScore, FunctionIdentity, FunctionVerdict,
RiskDistribution, ScoredFunction, SourceSpan,
};
fn mk_verdict(
name: &str,
file: &str,
complexity: u32,
coverage: f64,
crap_value: f64,
threshold: f64,
) -> FunctionVerdict {
let risk_level = crate::domain::crap::classify_risk(crap_value);
FunctionVerdict {
scored: ScoredFunction {
identity: FunctionIdentity {
file_path: file.to_string(),
qualified_name: name.to_string(),
span: SourceSpan {
start_line: 1,
end_line: 10,
start_column: 0,
end_column: 0,
},
},
complexity,
complexity_metric: ComplexityMetric::Cognitive,
coverage_percent: coverage,
branch_coverage_percent: None,
crap: CrapScore {
value: crap_value,
risk_level,
},
contributors: vec![],
},
threshold,
exceeds: crap_value > threshold,
diagnostic: None,
}
}
fn background_fixture() -> AnalysisResult {
let verdicts = vec![
mk_verdict("parse_lcov", "src/adapters/lcov.rs", 12, 100.0, 12.00, 25.0),
mk_verdict("walk_ast", "src/adapters/syn.rs", 18, 75.0, 23.06, 25.0),
mk_verdict(
"render_table",
"src/adapters/table.rs",
9,
60.0,
14.18,
25.0,
),
mk_verdict(
"apply_threshold",
"src/domain/threshold.rs",
4,
100.0,
4.00,
25.0,
),
mk_verdict(
"sort_verdicts",
"src/adapters/table.rs",
6,
0.0,
42.00,
25.0,
),
mk_verdict("parse_args", "src/cli/mod.rs", 22, 50.0, 63.50, 25.0),
];
let summary = compute_summary(&verdicts);
let passed = verdicts.iter().all(|v| !v.exceeds);
AnalysisResult {
functions: verdicts,
summary,
passed,
}
}
fn empty_result() -> AnalysisResult {
AnalysisResult {
functions: vec![],
summary: AnalysisSummary {
total_functions: 0,
total_files: 0,
exceeding_threshold: 0,
average_crap: 0.0,
median_crap: 0.0,
max_crap: None,
worst_function: None,
distribution: RiskDistribution {
low: 0,
acceptable: 0,
moderate: 0,
high: 0,
},
..Default::default()
},
passed: true,
}
}
#[test]
fn default_spec_is_noop_on_fixture() {
let r = background_fixture();
let view = apply(&r, ViewSpec::default());
assert_eq!(view.shown.len(), r.functions.len());
assert_eq!(view.eligible_count, r.functions.len());
assert!(!view.truncated);
assert!(std::ptr::eq(view.full, &r));
for w in view.shown.windows(2) {
assert!(
w[0].scored.crap.value >= w[1].scored.crap.value,
"expected CRAP descending; got {} then {}",
w[0].scored.crap.value,
w[1].scored.crap.value
);
}
}
#[test]
fn default_spec_empty_input_is_empty_view() {
let r = empty_result();
let view = apply(&r, ViewSpec::default());
assert!(view.shown.is_empty());
assert_eq!(view.eligible_count, 0);
assert!(!view.truncated);
assert!(view.full.passed);
}
#[test]
fn view_full_immutability_after_apply() {
let r = background_fixture();
let crap_before: Vec<f64> = r.functions.iter().map(|v| v.scored.crap.value).collect();
let view = apply(&r, ViewSpec::default());
assert!(std::ptr::eq(view.full, &r));
let crap_after: Vec<f64> = r.functions.iter().map(|v| v.scored.crap.value).collect();
assert_eq!(crap_before, crap_after);
}
#[test]
fn default_spec_preserves_identity_set() {
let r = background_fixture();
let view = apply(&r, ViewSpec::default());
let shown_names: std::collections::HashSet<&String> = view
.shown
.iter()
.map(|v| &v.scored.identity.qualified_name)
.collect();
let original_names: std::collections::HashSet<&String> = r
.functions
.iter()
.map(|v| &v.scored.identity.qualified_name)
.collect();
assert_eq!(shown_names, original_names);
}
#[test]
fn coverage_range_new_validation_table() {
type Case = (f64, f64, Result<(f64, f64), ()>);
let cases: &[Case] = &[
(0.0, 100.0, Ok((0.0, 100.0))),
(50.0, 50.0, Ok((50.0, 50.0))),
(1.0, 90.0, Ok((1.0, 90.0))),
(-0.1, 50.0, Err(())),
(50.0, 100.1, Err(())),
(90.0, 50.0, Err(())),
(100.0, 0.0, Err(())),
];
for (min, max, expect) in cases {
let got = CoverageRange::new(*min, *max);
match (got, expect) {
(Ok(r), Ok((emin, emax))) => {
assert!(
(r.min - emin).abs() < 1e-9 && (r.max - emax).abs() < 1e-9,
"min={min}, max={max}: got {r:?}, expected ({emin}, {emax})"
);
}
(Err(_), Err(())) => {} (got, expect) => panic!("min={min}, max={max}: got {got:?}, expected {expect:?}"),
}
}
}
#[test]
fn coverage_range_error_variants() {
let oor = CoverageRange::new(-1.0, 50.0).unwrap_err();
assert!(matches!(oor, CoverageRangeError::OutOfRange { .. }));
let mxm = CoverageRange::new(80.0, 20.0).unwrap_err();
assert!(matches!(mxm, CoverageRangeError::MinExceedsMax { .. }));
}
#[test]
fn sort_stability_on_tied_crap() {
let foo = mk_verdict("foo", "src/a.rs", 5, 80.0, 12.0, 25.0);
let bar = mk_verdict("bar", "src/a.rs", 5, 80.0, 12.0, 25.0);
let r = AnalysisResult {
functions: vec![foo, bar],
summary: empty_result().summary, passed: true,
};
let view = apply(&r, ViewSpec::default());
assert_eq!(
view.shown[0].scored.identity.qualified_name,
"foo",
"stable sort must preserve input order on ties; got {:?}",
view.shown
.iter()
.map(|v| &v.scored.identity.qualified_name)
.collect::<Vec<_>>()
);
assert_eq!(view.shown[1].scored.identity.qualified_name, "bar");
}
#[test]
fn only_failing_filter_retains_only_exceeds_true() {
let r = background_fixture();
let spec = ViewSpec {
filters: Filters {
only_failing: true,
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert!(view.shown.iter().all(|v| v.exceeds));
for v in &view.shown {
assert!(v.scored.crap.value > v.threshold);
}
}
#[test]
fn coverage_range_filter_inclusive() {
let r = background_fixture();
let range = CoverageRange::new(50.0, 90.0).unwrap();
let spec = ViewSpec {
filters: Filters {
coverage_range: Some(range),
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert!(view.shown.iter().all(|v| {
let cov = v.scored.coverage_percent;
cov.is_finite() && (50.0..=90.0).contains(&cov)
}));
let manual_count = r
.functions
.iter()
.filter(|v| v.scored.coverage_percent.is_finite())
.filter(|v| (50.0..=90.0).contains(&v.scored.coverage_percent))
.count();
assert_eq!(view.eligible_count, manual_count);
}
#[test]
fn coverage_range_boundary_inclusive_50_low() {
let v = mk_verdict("at50", "src/a.rs", 1, 50.0, 1.0, 25.0);
let r = AnalysisResult {
functions: vec![v],
summary: empty_result().summary,
passed: true,
};
let range = CoverageRange::new(50.0, 90.0).unwrap();
let spec = ViewSpec {
filters: Filters {
coverage_range: Some(range),
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown.len(), 1);
}
#[test]
fn coverage_range_boundary_inclusive_90_high() {
let v = mk_verdict("at90", "src/a.rs", 1, 90.0, 1.0, 25.0);
let r = AnalysisResult {
functions: vec![v],
summary: empty_result().summary,
passed: true,
};
let range = CoverageRange::new(50.0, 90.0).unwrap();
let spec = ViewSpec {
filters: Filters {
coverage_range: Some(range),
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown.len(), 1);
}
#[test]
fn coverage_range_boundary_inclusive_below_low() {
let v = mk_verdict("just_under", "src/a.rs", 1, 49.9, 1.0, 25.0);
let r = AnalysisResult {
functions: vec![v],
summary: empty_result().summary,
passed: true,
};
let range = CoverageRange::new(50.0, 90.0).unwrap();
let spec = ViewSpec {
filters: Filters {
coverage_range: Some(range),
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert!(view.shown.is_empty());
}
#[test]
fn coverage_range_boundary_inclusive_above_high() {
let v = mk_verdict("just_over", "src/a.rs", 1, 90.1, 1.0, 25.0);
let r = AnalysisResult {
functions: vec![v],
summary: empty_result().summary,
passed: true,
};
let range = CoverageRange::new(50.0, 90.0).unwrap();
let spec = ViewSpec {
filters: Filters {
coverage_range: Some(range),
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert!(view.shown.is_empty());
}
#[test]
fn coverage_range_boundary_inclusive_zero_singleton() {
let v = mk_verdict("zero", "src/a.rs", 1, 0.0, 1.0, 25.0);
let r = AnalysisResult {
functions: vec![v],
summary: empty_result().summary,
passed: true,
};
let range = CoverageRange::new(0.0, 0.0).unwrap();
let spec = ViewSpec {
filters: Filters {
coverage_range: Some(range),
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown.len(), 1);
}
#[test]
fn coverage_range_boundary_inclusive_hundred_singleton() {
let v = mk_verdict("full", "src/a.rs", 1, 100.0, 1.0, 25.0);
let r = AnalysisResult {
functions: vec![v],
summary: empty_result().summary,
passed: true,
};
let range = CoverageRange::new(100.0, 100.0).unwrap();
let spec = ViewSpec {
filters: Filters {
coverage_range: Some(range),
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown.len(), 1);
}
#[test]
fn filters_and_compose() {
let r = background_fixture();
let range = CoverageRange::new(50.0, 100.0).unwrap();
let spec = ViewSpec {
filters: Filters {
only_failing: true,
coverage_range: Some(range),
},
..Default::default()
};
let view = apply(&r, spec);
for v in &view.shown {
assert!(v.exceeds);
let cov = v.scored.coverage_percent;
assert!((50.0..=100.0).contains(&cov));
}
}
#[test]
fn nan_coverage_excluded_from_range_filter() {
let v = mk_verdict("zero_lines", "src/a.rs", 1, f64::NAN, 1.0, 25.0);
let r = AnalysisResult {
functions: vec![v],
summary: empty_result().summary,
passed: true,
};
let range = CoverageRange::new(0.0, 100.0).unwrap();
let spec = ViewSpec {
filters: Filters {
coverage_range: Some(range),
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert!(view.shown.is_empty());
}
#[test]
fn sort_by_crap_descending() {
let r = background_fixture();
let spec = ViewSpec {
sort: SortKey::Crap,
..Default::default()
};
let view = apply(&r, spec);
for w in view.shown.windows(2) {
assert!(w[0].scored.crap.value >= w[1].scored.crap.value);
}
}
#[test]
fn sort_by_coverage_ascending() {
let r = background_fixture();
let spec = ViewSpec {
sort: SortKey::Coverage,
..Default::default()
};
let view = apply(&r, spec);
for w in view.shown.windows(2) {
assert!(w[0].scored.coverage_percent <= w[1].scored.coverage_percent);
}
}
#[test]
fn sort_by_complexity_descending() {
let r = background_fixture();
let spec = ViewSpec {
sort: SortKey::Complexity,
..Default::default()
};
let view = apply(&r, spec);
for w in view.shown.windows(2) {
assert!(w[0].scored.complexity >= w[1].scored.complexity);
}
}
#[test]
fn sort_by_path_alphabetical_then_crap() {
let r = background_fixture();
let spec = ViewSpec {
sort: SortKey::Path,
..Default::default()
};
let view = apply(&r, spec);
for w in view.shown.windows(2) {
let (a, b) = (
&w[0].scored.identity.file_path,
&w[1].scored.identity.file_path,
);
assert!(a <= b, "files not in ascending order: {a} then {b}");
}
for w in view.shown.windows(2) {
if w[0].scored.identity.file_path == w[1].scored.identity.file_path {
assert!(
w[0].scored.crap.value >= w[1].scored.crap.value,
"within file {}: CRAP not descending: {} then {}",
w[0].scored.identity.file_path,
w[0].scored.crap.value,
w[1].scored.crap.value
);
}
}
}
#[test]
fn sort_by_path_secondary_multi_file() {
let verdicts = vec![
mk_verdict("a_low", "src/a.rs", 1, 50.0, 5.0, 25.0),
mk_verdict("a_high", "src/a.rs", 1, 50.0, 30.0, 25.0),
mk_verdict("b_only", "src/b.rs", 1, 50.0, 10.0, 25.0),
mk_verdict("c_low", "src/c.rs", 1, 50.0, 1.0, 25.0),
mk_verdict("c_high", "src/c.rs", 1, 50.0, 50.0, 25.0),
];
let r = AnalysisResult {
functions: verdicts,
summary: empty_result().summary,
passed: true,
};
let spec = ViewSpec {
sort: SortKey::Path,
..Default::default()
};
let view = apply(&r, spec);
let names: Vec<&str> = view
.shown
.iter()
.map(|v| v.scored.identity.qualified_name.as_str())
.collect();
assert_eq!(
names,
vec!["a_high", "a_low", "b_only", "c_high", "c_low"],
"path sort with secondary CRAP-desc order wrong"
);
}
#[test]
fn nan_coverage_sorts_last_under_coverage_ascending() {
let verdicts = vec![
mk_verdict("c10", "src/a.rs", 1, 10.0, 1.0, 25.0),
mk_verdict("nan1", "src/a.rs", 1, f64::NAN, 1.0, 25.0),
mk_verdict("c50", "src/a.rs", 1, 50.0, 1.0, 25.0),
mk_verdict("nan2", "src/a.rs", 1, f64::NAN, 1.0, 25.0),
mk_verdict("c90", "src/a.rs", 1, 90.0, 1.0, 25.0),
];
let r = AnalysisResult {
functions: verdicts,
summary: empty_result().summary,
passed: true,
};
let spec = ViewSpec {
sort: SortKey::Coverage,
..Default::default()
};
let view = apply(&r, spec);
let coverages: Vec<f64> = view
.shown
.iter()
.map(|v| v.scored.coverage_percent)
.collect();
assert_eq!(coverages[0], 10.0);
assert_eq!(coverages[1], 50.0);
assert_eq!(coverages[2], 90.0);
assert!(coverages[3].is_nan());
assert!(coverages[4].is_nan());
}
#[test]
fn limit_truncates() {
let r = background_fixture();
let spec = ViewSpec {
limit: Some(3),
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown.len(), 3);
assert_eq!(view.eligible_count, 6);
assert!(view.truncated);
}
#[test]
fn limit_greater_than_eligible() {
let r = background_fixture();
let spec = ViewSpec {
limit: Some(100),
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown.len(), 6);
assert_eq!(view.eligible_count, 6);
assert!(!view.truncated);
}
#[test]
fn limit_none() {
let r = background_fixture();
let spec = ViewSpec {
limit: None,
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown.len(), view.eligible_count);
assert!(!view.truncated);
}
#[test]
fn limit_zero_treated_as_no_limit() {
let r = background_fixture();
let spec = ViewSpec {
limit: Some(0),
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown.len(), view.eligible_count);
assert!(!view.truncated);
}
#[test]
fn limit_equal_to_eligible_does_not_mark_truncated() {
let r = background_fixture();
assert_eq!(r.functions.len(), 6, "background fixture sanity");
let spec = ViewSpec {
limit: Some(6),
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown.len(), 6);
assert_eq!(view.eligible_count, 6);
assert!(!view.truncated, "limit == eligible must NOT mark truncated");
}
#[test]
fn order_filter_then_sort_then_truncate() {
let r = background_fixture();
let spec = ViewSpec {
filters: Filters {
only_failing: true,
..Default::default()
},
sort: SortKey::Coverage,
limit: Some(2),
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown.len(), 2);
for v in &view.shown {
assert!(v.exceeds);
}
assert!(view.shown[0].scored.coverage_percent <= view.shown[1].scored.coverage_percent);
let total_failing = r.functions.iter().filter(|v| v.exceeds).count();
assert_eq!(view.eligible_count, total_failing);
}
#[test]
fn truncation_does_not_change_gate() {
let verdicts = vec![
mk_verdict("ok", "src/a.rs", 1, 100.0, 1.0, 25.0),
mk_verdict("fail1", "src/a.rs", 1, 0.0, 50.0, 25.0),
mk_verdict("fail2", "src/a.rs", 1, 0.0, 60.0, 25.0),
mk_verdict("fail3", "src/a.rs", 1, 0.0, 70.0, 25.0),
];
let summary = compute_summary(&verdicts);
let passed = verdicts.iter().all(|v| !v.exceeds);
let r = AnalysisResult {
functions: verdicts,
summary,
passed,
};
let spec = ViewSpec {
limit: Some(1),
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown.len(), 1);
assert!(!view.full.passed);
assert_eq!(view.full.summary.exceeding_threshold, 3);
}
#[test]
fn filtering_does_not_change_gate() {
let verdicts = vec![
mk_verdict("ok", "src/a.rs", 1, 100.0, 1.0, 25.0),
mk_verdict("fail1", "src/a.rs", 1, 0.0, 50.0, 25.0),
mk_verdict("fail2", "src/a.rs", 1, 0.0, 60.0, 25.0),
mk_verdict("fail3", "src/a.rs", 1, 0.0, 70.0, 25.0),
];
let summary = compute_summary(&verdicts);
let r = AnalysisResult {
functions: verdicts,
summary,
passed: false,
};
let range = CoverageRange::new(99.0, 100.0).unwrap();
let spec = ViewSpec {
filters: Filters {
coverage_range: Some(range),
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert!(view.shown.iter().all(|v| !v.exceeds));
assert!(!view.full.passed);
assert_eq!(view.full.summary.exceeding_threshold, 3);
}
#[test]
fn shown_summary_over_shown_subset() {
let r = background_fixture();
let spec = ViewSpec {
filters: Filters {
only_failing: true,
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown_summary.total_functions, view.shown.len());
assert_eq!(
view.shown_summary.exceeding_threshold,
view.shown.len(),
"every shown row exceeds, so shown_summary should report all"
);
let manual_avg: f64 =
view.shown.iter().map(|v| v.scored.crap.value).sum::<f64>() / view.shown.len() as f64;
assert!((view.shown_summary.average_crap - manual_avg).abs() < 1e-9);
}
#[test]
fn shown_summary_differs_from_full() {
let verdicts = vec![
mk_verdict("ok1", "src/a.rs", 1, 100.0, 1.0, 25.0),
mk_verdict("ok2", "src/a.rs", 1, 100.0, 2.0, 25.0),
mk_verdict("ok3", "src/a.rs", 1, 100.0, 3.0, 25.0),
mk_verdict("fail1", "src/a.rs", 1, 0.0, 50.0, 25.0),
mk_verdict("fail2", "src/a.rs", 1, 0.0, 60.0, 25.0),
mk_verdict("fail3", "src/a.rs", 1, 0.0, 70.0, 25.0),
];
let summary = compute_summary(&verdicts);
let r = AnalysisResult {
functions: verdicts,
summary,
passed: false,
};
let spec = ViewSpec {
filters: Filters {
only_failing: true,
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.full.summary.total_functions, 6);
assert_eq!(view.shown_summary.total_functions, 3);
}
#[test]
fn all_filtered_out_produces_empty_shown() {
let v = mk_verdict("low_cov", "src/a.rs", 1, 50.0, 1.0, 25.0);
let r = AnalysisResult {
functions: vec![v],
summary: empty_result().summary,
passed: true,
};
let range = CoverageRange::new(95.0, 100.0).unwrap();
let spec = ViewSpec {
filters: Filters {
coverage_range: Some(range),
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert!(view.shown.is_empty());
assert_eq!(view.eligible_count, 0);
assert!(!view.truncated);
}
#[test]
fn display_predicate_default_spec_is_false() {
let r = background_fixture();
let view = apply(&r, ViewSpec::default());
assert!(!should_render_view_line(&view));
}
#[test]
fn display_predicate_sort_only_is_false() {
let r = background_fixture();
let spec = ViewSpec {
sort: SortKey::Coverage,
..Default::default()
};
let view = apply(&r, spec);
assert!(!should_render_view_line(&view));
}
#[test]
fn display_predicate_top_truncating_is_true() {
let r = background_fixture();
let spec = ViewSpec {
limit: Some(2),
..Default::default()
};
let view = apply(&r, spec);
assert!(should_render_view_line(&view));
}
#[test]
fn display_predicate_coverage_filter_excluding_is_true() {
let r = background_fixture();
let range = CoverageRange::new(99.0, 100.0).unwrap();
let spec = ViewSpec {
filters: Filters {
coverage_range: Some(range),
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert!(should_render_view_line(&view));
}
#[test]
fn display_predicate_only_failing_reducing_is_true() {
let r = background_fixture();
let spec = ViewSpec {
filters: Filters {
only_failing: true,
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert!(should_render_view_line(&view));
}
#[test]
fn no_group_by_means_no_grouped_block() {
let r = background_fixture();
let view = apply(&r, ViewSpec::default());
assert!(view.grouped.is_none());
}
#[test]
fn group_by_file_populates_grouped_block() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
..Default::default()
};
let view = apply(&r, spec);
let grouped = view.grouped.as_ref().expect("grouped block expected");
assert_eq!(grouped.key, GroupKey::File);
assert_eq!(grouped.files.len(), 5);
assert_eq!(grouped.eligible_count, 5);
assert!(!grouped.truncated);
}
#[test]
fn group_by_file_does_not_truncate_function_shown() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
limit: Some(2),
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.shown.len(), r.functions.len());
assert!(!view.truncated);
let grouped = view.grouped.as_ref().unwrap();
assert_eq!(grouped.files.len(), 2);
assert!(grouped.truncated);
assert_eq!(grouped.eligible_count, 5);
}
#[test]
fn group_by_file_keeps_gate_unchanged() {
let r = background_fixture();
let baseline_passed = r.passed;
let baseline_total = r.summary.total_functions;
let baseline_exceeding = r.summary.exceeding_threshold;
let spec = ViewSpec {
group_by: Some(GroupKey::File),
limit: Some(1),
..Default::default()
};
let view = apply(&r, spec);
assert_eq!(view.full.passed, baseline_passed);
assert_eq!(view.full.summary.total_functions, baseline_total);
assert_eq!(view.full.summary.exceeding_threshold, baseline_exceeding);
}
#[test]
fn group_by_file_default_sort_is_avg_crap_desc() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
..Default::default()
};
let view = apply(&r, spec);
let files = &view.grouped.as_ref().unwrap().files;
for w in files.windows(2) {
assert!(
w[0].average_crap >= w[1].average_crap,
"files not in average_crap descending order"
);
}
}
#[test]
fn group_by_file_sort_by_coverage_ascending() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
sort: SortKey::Coverage,
..Default::default()
};
let view = apply(&r, spec);
let files = &view.grouped.as_ref().unwrap().files;
for w in files.windows(2) {
assert!(w[0].average_coverage <= w[1].average_coverage);
}
}
#[test]
fn group_by_file_sort_by_complexity_descending() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
sort: SortKey::Complexity,
..Default::default()
};
let view = apply(&r, spec);
let files = &view.grouped.as_ref().unwrap().files;
for w in files.windows(2) {
assert!(w[0].max_complexity >= w[1].max_complexity);
}
}
#[test]
fn group_by_file_sort_by_path_alphabetical() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
sort: SortKey::Path,
..Default::default()
};
let view = apply(&r, spec);
let files = &view.grouped.as_ref().unwrap().files;
for w in files.windows(2) {
assert!(w[0].file_path <= w[1].file_path);
}
}
#[test]
fn group_by_file_truncate_files() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
limit: Some(3),
..Default::default()
};
let view = apply(&r, spec);
let grouped = view.grouped.as_ref().unwrap();
assert_eq!(grouped.files.len(), 3);
assert!(grouped.truncated);
assert_eq!(grouped.eligible_count, 5);
}
#[test]
fn group_by_file_filters_compose_before_grouping() {
let r = background_fixture();
let spec = ViewSpec {
filters: Filters {
only_failing: true,
..Default::default()
},
group_by: Some(GroupKey::File),
..Default::default()
};
let view = apply(&r, spec);
let grouped = view.grouped.as_ref().unwrap();
assert_eq!(grouped.files.len(), 2);
for f in &grouped.files {
assert!(f.exceeding_count >= 1);
}
}
#[test]
fn group_by_file_empty_input_produces_empty_files() {
let r = empty_result();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
..Default::default()
};
let view = apply(&r, spec);
let grouped = view.grouped.as_ref().unwrap();
assert!(grouped.files.is_empty());
assert_eq!(grouped.eligible_count, 0);
assert!(!grouped.truncated);
}
#[test]
fn display_predicate_group_by_only_default_input_is_false() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
..Default::default()
};
let view = apply(&r, spec);
assert!(!should_render_view_line(&view));
}
#[test]
fn display_predicate_group_by_truncating_files_is_true() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
limit: Some(2),
..Default::default()
};
let view = apply(&r, spec);
assert!(should_render_view_line(&view));
}
#[test]
fn group_by_file_top_zero_is_no_limit() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
limit: Some(0),
..Default::default()
};
let view = apply(&r, spec);
let grouped = view.grouped.as_ref().expect("grouping active");
assert!(!grouped.truncated);
assert_eq!(grouped.files.len(), 5);
}
#[test]
fn group_by_file_limit_equal_to_file_count_is_not_truncated() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
limit: Some(5),
..Default::default()
};
let view = apply(&r, spec);
let grouped = view.grouped.as_ref().expect("grouping active");
assert!(!grouped.truncated);
assert_eq!(grouped.files.len(), 5);
}
#[test]
fn display_predicate_full_grouping_no_reduction_is_false() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
..Default::default()
};
let view = apply(&r, spec);
assert!(!should_render_view_line(&view));
}
#[test]
fn display_predicate_grouping_reduces_files_is_true() {
let r = background_fixture();
let spec = ViewSpec {
group_by: Some(GroupKey::File),
filters: Filters {
only_failing: true,
..Default::default()
},
..Default::default()
};
let view = apply(&r, spec);
assert!(should_render_view_line(&view));
}
}
#[cfg(test)]
mod proptests {
use super::*;
use crate::test_strategies::{arb_analysis_result, arb_verdict_with_nan_coverage};
use proptest::prelude::*;
fn legacy_sort_order(result: &AnalysisResult) -> Vec<&FunctionVerdict> {
let mut sorted: Vec<&FunctionVerdict> = result.functions.iter().collect();
sorted.sort_by(|a, b| {
b.scored
.crap
.value
.partial_cmp(&a.scored.crap.value)
.unwrap_or(std::cmp::Ordering::Equal)
});
sorted
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn prop_default_spec_order_matches_legacy_sort(result in arb_analysis_result()) {
let view = apply(&result, ViewSpec::default());
let legacy = legacy_sort_order(&result);
prop_assert_eq!(view.shown.len(), legacy.len());
for (a, b) in view.shown.iter().zip(legacy.iter()) {
prop_assert!(std::ptr::eq(*a, *b));
}
}
#[test]
fn prop_default_spec_preserves_identity(result in arb_analysis_result()) {
let view = apply(&result, ViewSpec::default());
let shown_identities: std::collections::HashSet<&crate::domain::types::FunctionIdentity> =
view.shown.iter().map(|v| &v.scored.identity).collect();
let original_identities: std::collections::HashSet<&crate::domain::types::FunctionIdentity> =
result.functions.iter().map(|v| &v.scored.identity).collect();
prop_assert_eq!(shown_identities, original_identities);
}
#[test]
fn prop_default_spec_preserves_summary(result in arb_analysis_result()) {
let view = apply(&result, ViewSpec::default());
prop_assert!(std::ptr::eq(view.full, &result));
prop_assert_eq!(view.full.summary.total_functions, result.summary.total_functions);
}
#[test]
fn prop_display_predicate_biconditional(result in arb_analysis_result()) {
let view = apply(&result, ViewSpec::default());
let computed = should_render_view_line(&view);
let expected = view.eligible_count < view.full.functions.len() || view.truncated;
prop_assert_eq!(computed, expected);
prop_assert!(!computed);
}
#[test]
fn prop_apply_never_panics_with_nan_coverage(
verdicts in prop::collection::vec(arb_verdict_with_nan_coverage(), 0..50)
) {
let result = AnalysisResult {
functions: verdicts.clone(),
summary: crate::domain::types::AnalysisSummary {
total_functions: verdicts.len(),
total_files: verdicts.len(),
exceeding_threshold: 0,
average_crap: 0.0,
median_crap: 0.0,
max_crap: None,
worst_function: None,
distribution: crate::domain::types::RiskDistribution {
low: 0, acceptable: 0, moderate: 0, high: 0,
},
..Default::default()
},
passed: true,
};
for sort in [SortKey::Crap, SortKey::Coverage, SortKey::Complexity, SortKey::Path] {
let spec = ViewSpec {
filters: Filters {
coverage_range: Some(CoverageRange::new(0.0, 100.0).unwrap()),
..Default::default()
},
sort,
limit: Some(10),
..Default::default()
};
let _ = apply(&result, spec);
}
}
}
}