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