Skip to main content

crap4rust/
app.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License or Apache License, Version 2.0
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5use std::collections::HashMap;
6use std::process::ExitCode;
7
8use anyhow::{Context, Result, bail};
9
10use crate::cli::Args;
11use crate::coverage;
12use crate::manifest;
13use crate::model::{Config, FunctionReport, ProjectReport, Verdict};
14use crate::report;
15use crate::source;
16
17pub fn run(args: Args) -> Result<ExitCode> {
18    let config = Config {
19        coverage_path: args.coverage,
20        manifest_path: args.manifest_path,
21        packages: args.package,
22        features: args.features,
23        all_features: args.all_features,
24        no_default_features: args.no_default_features,
25        include_test_targets: args.include_test_targets,
26        exclude_paths: args.exclude_path,
27        threshold: args.threshold,
28        warn_threshold: args.warn_threshold,
29        project_threshold: args.project_threshold,
30        strict: args.strict,
31        warn_only: args.warn_only,
32        output_format: args.output_format,
33    };
34
35    let packages = manifest::resolve_packages(&config)?;
36    let coverage_path = coverage::ensure_coverage_path(&config, &packages)?;
37    let mut functions = Vec::new();
38    for package in &packages {
39        let mut package_functions = source::discover_functions(package)
40            .with_context(|| format!("failed to discover functions in package {}", package.name))?;
41        functions.append(&mut package_functions);
42    }
43    if functions.is_empty() {
44        bail!("no Rust functions were discovered in the selected packages");
45    }
46
47    let coverage_records = coverage::load_coverage_records(&coverage_path)?;
48    if coverage_records.is_empty() {
49        bail!("coverage file did not contain any function records");
50    }
51
52    let mut coverage_index: HashMap<(String, usize), crate::model::CoverageRecord> = HashMap::new();
53    for record in coverage_records {
54        let key = (record.path_key.clone(), record.line);
55        coverage_index
56            .entry(key)
57            .and_modify(|existing| {
58                existing.covered_regions += record.covered_regions;
59                existing.total_regions += record.total_regions;
60            })
61            .or_insert(record);
62    }
63
64    let matched_count = functions
65        .iter()
66        .filter(|function| match_function_coverage(function, &coverage_index).is_some())
67        .count();
68    if matched_count == 0 {
69        bail!(
70            "coverage data could not be matched to any discovered function by file path and line"
71        );
72    }
73
74    let mut reports = functions
75        .into_iter()
76        .map(|function| {
77            let coverage = match_function_coverage(&function, &coverage_index)
78                .map_or(0.0, |record| record.coverage_ratio());
79            let crap_score = compute_crap_score(function.complexity, coverage);
80            let verdict = classify(crap_score, config.threshold, config.warn_threshold);
81
82            FunctionReport {
83                package_name: function.package_name,
84                name: function.name,
85                relative_file: function.relative_file,
86                line: function.line,
87                complexity: function.complexity,
88                coverage,
89                crap_score,
90                verdict,
91            }
92        })
93        .collect::<Vec<_>>();
94
95    reports.sort_by(|left, right| {
96        right
97            .crap_score
98            .partial_cmp(&left.crap_score)
99            .unwrap_or(std::cmp::Ordering::Equal)
100            .then_with(|| left.name.cmp(&right.name))
101    });
102
103    let crappy_functions = reports
104        .iter()
105        .filter(|function| function.verdict == Verdict::Crappy)
106        .count();
107    let total_functions = reports.len();
108    let crappy_percent = if total_functions == 0 {
109        0.0
110    } else {
111        (crappy_functions as f64 / total_functions as f64) * 100.0
112    };
113    let project_verdict = if project_fails(crappy_functions, crappy_percent, &config) {
114        Verdict::Crappy
115    } else if crappy_functions > 0
116        || reports
117            .iter()
118            .any(|function| function.verdict == Verdict::Warn)
119    {
120        Verdict::Warn
121    } else {
122        Verdict::Clean
123    };
124
125    let report_data = ProjectReport {
126        scope_name: packages
127            .iter()
128            .map(|package| package.name.clone())
129            .collect::<Vec<_>>()
130            .join(", "),
131        total_functions,
132        crappy_functions,
133        crappy_percent,
134        verdict: project_verdict,
135        functions: reports,
136    };
137
138    report::print_report(&report_data, &config);
139
140    Ok(determine_exit_code(&report_data, &config))
141}
142
143pub fn classify(score: f64, threshold: f64, warn_threshold: f64) -> Verdict {
144    if score > threshold {
145        Verdict::Crappy
146    } else if score >= warn_threshold {
147        Verdict::Warn
148    } else {
149        Verdict::Clean
150    }
151}
152
153pub fn match_function_coverage(
154    function: &crate::model::SourceFunction,
155    coverage_index: &HashMap<(String, usize), crate::model::CoverageRecord>,
156) -> Option<crate::model::CoverageRecord> {
157    if let Some(record) = coverage_index.get(&(function.path_key.clone(), function.line)) {
158        return Some(record.clone());
159    }
160
161    coverage_index
162        .iter()
163        .filter(|((path_key, line), _)| {
164            path_key == &function.path_key && *line >= function.line && *line <= function.end_line
165        })
166        .min_by_key(|((_, line), _)| *line - function.line)
167        .map(|(_, record)| record.clone())
168}
169
170pub fn compute_crap_score(complexity: u32, coverage: f64) -> f64 {
171    let complexity = f64::from(complexity);
172    complexity.powi(2) * (1.0 - coverage).powi(3) + complexity
173}
174
175fn determine_exit_code(report: &ProjectReport, config: &Config) -> ExitCode {
176    if config.warn_only {
177        return ExitCode::SUCCESS;
178    }
179
180    if project_fails(report.crappy_functions, report.crappy_percent, config) {
181        ExitCode::from(1)
182    } else {
183        ExitCode::SUCCESS
184    }
185}
186
187pub fn project_fails(crappy_functions: usize, crappy_percent: f64, config: &Config) -> bool {
188    if config.strict {
189        crappy_functions > 0
190    } else {
191        crappy_percent > config.project_threshold
192    }
193}