Skip to main content

fallow_api/
health_codeclimate.rs

1//! Shared health CodeClimate issue construction.
2
3use std::path::Path;
4
5use fallow_output::{
6    CodeClimateIssue, CodeClimateIssueInput, CodeClimateSeverity, ComplexityViolation,
7    CoverageIntelligenceFinding, CoverageIntelligenceRecommendation, CoverageIntelligenceVerdict,
8    ExceededThreshold, FindingSeverity, HealthReport, RuntimeCoverageFinding,
9    RuntimeCoverageVerdict, UntestedExportFinding, UntestedFileFinding, build_codeclimate_issue,
10    codeclimate_fingerprint_hash, normalize_uri,
11};
12
13struct HealthCodeClimateContext<'a> {
14    root: &'a Path,
15    cyc_t: u16,
16    cog_t: u16,
17    crap_t: f64,
18}
19
20impl HealthCodeClimateContext<'_> {
21    fn complexity_issue(&self, finding: &ComplexityViolation) -> CodeClimateIssue {
22        let path = codeclimate_path(&finding.path, self.root);
23        let check_name = complexity_check_name(finding);
24        let line_str = finding.line.to_string();
25        let fp = codeclimate_fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
26        build_codeclimate_issue(CodeClimateIssueInput {
27            check_name,
28            description: &self.complexity_description(finding),
29            severity: health_finding_severity(finding.severity),
30            category: "Complexity",
31            path: &path,
32            begin_line: Some(finding.line),
33            fingerprint: &fp,
34        })
35    }
36
37    fn complexity_description(&self, finding: &ComplexityViolation) -> String {
38        match finding.exceeded {
39            ExceededThreshold::Both => format!(
40                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
41                finding.name, finding.cyclomatic, self.cyc_t, finding.cognitive, self.cog_t
42            ),
43            ExceededThreshold::Cyclomatic => format!(
44                "'{}' has cyclomatic complexity {} (threshold: {})",
45                finding.name, finding.cyclomatic, self.cyc_t
46            ),
47            ExceededThreshold::Cognitive => format!(
48                "'{}' has cognitive complexity {} (threshold: {})",
49                finding.name, finding.cognitive, self.cog_t
50            ),
51            ExceededThreshold::Crap
52            | ExceededThreshold::CyclomaticCrap
53            | ExceededThreshold::CognitiveCrap
54            | ExceededThreshold::All => {
55                let crap = finding.crap.unwrap_or(0.0);
56                let coverage = finding
57                    .coverage_pct
58                    .map(|pct| format!(", coverage {pct:.0}%"))
59                    .unwrap_or_default();
60                format!(
61                    "'{}' has CRAP score {crap:.1} (threshold: {:.1}, cyclomatic {}{coverage})",
62                    finding.name, self.crap_t, finding.cyclomatic,
63                )
64            }
65        }
66    }
67
68    fn runtime_coverage_issue(&self, finding: &RuntimeCoverageFinding) -> CodeClimateIssue {
69        let path = codeclimate_path(&finding.path, self.root);
70        let check_name = runtime_coverage_check_name(finding.verdict);
71        let invocations_hint = finding.invocations.map_or_else(
72            || "untracked".to_owned(),
73            |hits| format!("{hits} invocations"),
74        );
75        let description = format!(
76            "'{}' runtime coverage verdict: {} ({})",
77            finding.function,
78            finding.verdict.human_label(),
79            invocations_hint,
80        );
81        let fp = codeclimate_fingerprint_hash(&[
82            check_name,
83            &path,
84            &finding.line.to_string(),
85            &finding.function,
86        ]);
87        build_codeclimate_issue(CodeClimateIssueInput {
88            check_name,
89            description: &description,
90            severity: runtime_coverage_severity(finding.verdict),
91            category: "Bug Risk",
92            path: &path,
93            begin_line: Some(finding.line),
94            fingerprint: &fp,
95        })
96    }
97
98    fn coverage_intelligence_issue(
99        &self,
100        finding: &CoverageIntelligenceFinding,
101    ) -> Option<CodeClimateIssue> {
102        let severity = coverage_intelligence_severity(finding.verdict)?;
103        let path = codeclimate_path(&finding.path, self.root);
104        let check_name = coverage_intelligence_check_name(finding.recommendation);
105        let identity = finding.identity.as_deref().unwrap_or("code");
106        let description = format!(
107            "'{}' coverage intelligence verdict: {} ({})",
108            identity, finding.verdict, finding.recommendation,
109        );
110        let fp = codeclimate_fingerprint_hash(&[
111            check_name,
112            &path,
113            &finding.line.to_string(),
114            identity,
115            &finding.id,
116        ]);
117        Some(build_codeclimate_issue(CodeClimateIssueInput {
118            check_name,
119            description: &description,
120            severity,
121            category: "Bug Risk",
122            path: &path,
123            begin_line: Some(finding.line),
124            fingerprint: &fp,
125        }))
126    }
127
128    fn untested_file_issue(&self, item: &UntestedFileFinding) -> CodeClimateIssue {
129        let path = codeclimate_path(&item.file.path, self.root);
130        let description = format!(
131            "File is runtime-reachable but has no test dependency path ({} value export{})",
132            item.file.value_export_count,
133            if item.file.value_export_count == 1 {
134                ""
135            } else {
136                "s"
137            },
138        );
139        let fp = codeclimate_fingerprint_hash(&["fallow/untested-file", &path]);
140        build_codeclimate_issue(CodeClimateIssueInput {
141            check_name: "fallow/untested-file",
142            description: &description,
143            severity: CodeClimateSeverity::Minor,
144            category: "Coverage",
145            path: &path,
146            begin_line: None,
147            fingerprint: &fp,
148        })
149    }
150
151    fn untested_export_issue(&self, item: &UntestedExportFinding) -> CodeClimateIssue {
152        let path = codeclimate_path(&item.export.path, self.root);
153        let description = format!(
154            "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
155            item.export.export_name
156        );
157        let line_str = item.export.line.to_string();
158        let fp = codeclimate_fingerprint_hash(&[
159            "fallow/untested-export",
160            &path,
161            &line_str,
162            &item.export.export_name,
163        ]);
164        build_codeclimate_issue(CodeClimateIssueInput {
165            check_name: "fallow/untested-export",
166            description: &description,
167            severity: CodeClimateSeverity::Minor,
168            category: "Coverage",
169            path: &path,
170            begin_line: Some(item.export.line),
171            fingerprint: &fp,
172        })
173    }
174}
175
176/// Build CodeClimate issues from health / complexity analysis results.
177#[must_use]
178pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
179    let mut issues = Vec::new();
180    let ctx = HealthCodeClimateContext {
181        root,
182        cyc_t: report.summary.max_cyclomatic_threshold,
183        cog_t: report.summary.max_cognitive_threshold,
184        crap_t: report.summary.max_crap_threshold,
185    };
186
187    for finding in &report.findings {
188        issues.push(ctx.complexity_issue(finding));
189    }
190
191    if let Some(ref production) = report.runtime_coverage {
192        for finding in &production.findings {
193            issues.push(ctx.runtime_coverage_issue(finding));
194        }
195    }
196
197    if let Some(ref intelligence) = report.coverage_intelligence {
198        for finding in &intelligence.findings {
199            if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
200                issues.push(issue);
201            }
202        }
203    }
204
205    if let Some(ref gaps) = report.coverage_gaps {
206        for item in &gaps.files {
207            issues.push(ctx.untested_file_issue(item));
208        }
209
210        for item in &gaps.exports {
211            issues.push(ctx.untested_export_issue(item));
212        }
213    }
214
215    issues
216}
217
218fn codeclimate_path(path: &Path, root: &Path) -> String {
219    normalize_uri(
220        &path
221            .strip_prefix(root)
222            .unwrap_or(path)
223            .display()
224            .to_string(),
225    )
226}
227
228const fn coverage_intelligence_check_name(
229    recommendation: CoverageIntelligenceRecommendation,
230) -> &'static str {
231    match recommendation {
232        CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
233            "fallow/coverage-intelligence-risky-change"
234        }
235        CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
236            "fallow/coverage-intelligence-delete"
237        }
238        CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
239            "fallow/coverage-intelligence-review"
240        }
241        CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
242            "fallow/coverage-intelligence-refactor"
243        }
244    }
245}
246
247const fn complexity_check_name(finding: &ComplexityViolation) -> &'static str {
248    match finding.exceeded {
249        ExceededThreshold::Both => "fallow/high-complexity",
250        ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
251        ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
252        ExceededThreshold::Crap
253        | ExceededThreshold::CyclomaticCrap
254        | ExceededThreshold::CognitiveCrap
255        | ExceededThreshold::All => "fallow/high-crap-score",
256    }
257}
258
259const fn health_finding_severity(severity: FindingSeverity) -> CodeClimateSeverity {
260    match severity {
261        FindingSeverity::Critical => CodeClimateSeverity::Critical,
262        FindingSeverity::High => CodeClimateSeverity::Major,
263        FindingSeverity::Moderate => CodeClimateSeverity::Minor,
264    }
265}
266
267const fn runtime_coverage_check_name(verdict: RuntimeCoverageVerdict) -> &'static str {
268    match verdict {
269        RuntimeCoverageVerdict::SafeToDelete => "fallow/runtime-safe-to-delete",
270        RuntimeCoverageVerdict::ReviewRequired => "fallow/runtime-review-required",
271        RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
272        RuntimeCoverageVerdict::CoverageUnavailable => "fallow/runtime-coverage-unavailable",
273        RuntimeCoverageVerdict::Active | RuntimeCoverageVerdict::Unknown => {
274            "fallow/runtime-coverage"
275        }
276    }
277}
278
279const fn runtime_coverage_severity(verdict: RuntimeCoverageVerdict) -> CodeClimateSeverity {
280    match verdict {
281        RuntimeCoverageVerdict::SafeToDelete => CodeClimateSeverity::Critical,
282        RuntimeCoverageVerdict::ReviewRequired => CodeClimateSeverity::Major,
283        _ => CodeClimateSeverity::Minor,
284    }
285}
286
287const fn coverage_intelligence_severity(
288    verdict: CoverageIntelligenceVerdict,
289) -> Option<CodeClimateSeverity> {
290    match verdict {
291        CoverageIntelligenceVerdict::RiskyChangeDetected
292        | CoverageIntelligenceVerdict::HighConfidenceDelete => Some(CodeClimateSeverity::Major),
293        CoverageIntelligenceVerdict::ReviewRequired
294        | CoverageIntelligenceVerdict::RefactorCarefully => Some(CodeClimateSeverity::Minor),
295        CoverageIntelligenceVerdict::Clean | CoverageIntelligenceVerdict::Unknown => None,
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use std::path::{Path, PathBuf};
302
303    use fallow_output::{
304        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthReport, HealthSummary,
305    };
306
307    use super::*;
308
309    #[test]
310    fn health_codeclimate_uses_relative_normalized_paths() {
311        let report = HealthReport {
312            summary: HealthSummary {
313                max_cyclomatic_threshold: 10,
314                max_cognitive_threshold: 8,
315                max_crap_threshold: 30.0,
316                ..HealthSummary::default()
317            },
318            findings: vec![
319                ComplexityViolation {
320                    path: PathBuf::from("/root/app/[id]/page.tsx"),
321                    name: "render".to_string(),
322                    line: 7,
323                    col: 0,
324                    cyclomatic: 12,
325                    cognitive: 9,
326                    line_count: 20,
327                    param_count: 1,
328                    react_hook_count: 0,
329                    react_jsx_max_depth: 0,
330                    react_prop_count: 0,
331                    react_hook_profile: None,
332                    exceeded: ExceededThreshold::Both,
333                    severity: FindingSeverity::High,
334                    coverage_pct: None,
335                    crap: None,
336                    coverage_tier: None,
337                    coverage_source: None,
338                    inherited_from: None,
339                    component_rollup: None,
340                    contributions: Vec::new(),
341                    effective_thresholds: None,
342                    threshold_source: None,
343                }
344                .into(),
345            ],
346            ..HealthReport::default()
347        };
348
349        let issues = build_health_codeclimate(&report, Path::new("/root"));
350
351        assert_eq!(issues.len(), 1);
352        let issue = &issues[0];
353        assert_eq!(issue.check_name, "fallow/high-complexity");
354        assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
355        assert_eq!(issue.location.lines.begin, 7);
356        assert_eq!(issue.severity, CodeClimateSeverity::Major);
357    }
358}