1use 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#[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}