1use std::path::Path;
13
14use rustc_hash::{FxHashMap, FxHashSet};
15
16use crate::audit_keys::{health_finding_key, remap_key_for_renames};
17
18#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct ComplexityMetrics {
21 pub line: u32,
23 pub cyclomatic: u16,
25 pub cognitive: u16,
27 pub crap: Option<f64>,
29}
30
31impl ComplexityMetrics {
32 #[must_use]
34 pub const fn of(finding: &fallow_output::ComplexityViolation) -> Self {
35 Self {
36 line: finding.line,
37 cyclomatic: finding.cyclomatic,
38 cognitive: finding.cognitive,
39 crap: finding.crap,
40 }
41 }
42
43 fn same_values(&self, other: &Self) -> bool {
46 self.cyclomatic == other.cyclomatic
47 && self.cognitive == other.cognitive
48 && self.crap.map(f64::to_bits) == other.crap.map(f64::to_bits)
49 }
50}
51
52pub type ComplexityBaseline = FxHashMap<String, Vec<ComplexityMetrics>>;
55
56#[must_use]
58pub fn complexity_baseline(
59 report: &fallow_output::HealthReport,
60 root: &Path,
61) -> ComplexityBaseline {
62 let mut baseline = ComplexityBaseline::default();
63 for finding in &report.findings {
64 baseline
65 .entry(health_finding_key(finding, root))
66 .or_default()
67 .push(ComplexityMetrics::of(finding));
68 }
69 sort_by_line(&mut baseline);
70 baseline
71}
72
73#[must_use]
75pub fn complexity_baseline_keys(baseline: &ComplexityBaseline) -> FxHashSet<String> {
76 baseline.keys().cloned().collect()
77}
78
79#[must_use]
85#[expect(
86 clippy::implicit_hasher,
87 reason = "fallow standardizes on FxHashMap across audit attribution keys"
88)]
89pub fn remap_complexity_baseline_for_renames(
90 baseline: ComplexityBaseline,
91 renames: &FxHashMap<String, String>,
92) -> ComplexityBaseline {
93 let mut remapped = ComplexityBaseline::default();
94 for (key, findings) in baseline {
95 remapped
96 .entry(remap_key_for_renames(&key, renames))
97 .or_default()
98 .extend(findings);
99 }
100 sort_by_line(&mut remapped);
101 remapped
102}
103
104fn sort_by_line(baseline: &mut ComplexityBaseline) {
105 for findings in baseline.values_mut() {
106 findings.sort_by_key(|metrics| metrics.line);
107 }
108}
109
110fn exceeded_metric_increased(
114 head: &fallow_output::ComplexityViolation,
115 base: &ComplexityMetrics,
116) -> bool {
117 let exceeded = head.exceeded;
118 (exceeded.includes_cyclomatic() && head.cyclomatic > base.cyclomatic)
119 || (exceeded.includes_cognitive() && head.cognitive > base.cognitive)
120 || (exceeded.includes_crap()
121 && match (head.crap, base.crap) {
122 (Some(head), Some(base)) => head > base,
123 (Some(_), None) => true,
124 (None, _) => false,
125 })
126}
127
128#[must_use]
148pub fn classify_complexity_findings(
149 findings: &[fallow_output::HealthFinding],
150 root: &Path,
151 base: &ComplexityBaseline,
152) -> Vec<bool> {
153 let mut groups: FxHashMap<String, Vec<usize>> = FxHashMap::default();
154 for (index, finding) in findings.iter().enumerate() {
155 groups
156 .entry(health_finding_key(finding, root))
157 .or_default()
158 .push(index);
159 }
160 let mut introduced = vec![true; findings.len()];
161 for (key, mut heads) in groups {
162 let Some(bases) = base.get(&key) else {
163 continue;
164 };
165 heads.sort_by_key(|&index| (findings[index].line, findings[index].col));
166 let mut unmatched_bases: Vec<Option<&ComplexityMetrics>> = bases.iter().map(Some).collect();
167 let mut unmatched_heads = Vec::new();
168 for index in heads {
169 let head = ComplexityMetrics::of(&findings[index]);
170 let same = unmatched_bases
171 .iter_mut()
172 .find(|slot| slot.is_some_and(|base| base.same_values(&head)));
173 match same {
174 Some(slot) => {
175 *slot = None;
176 introduced[index] = false;
177 }
178 None => unmatched_heads.push(index),
179 }
180 }
181 let mut remaining_bases = unmatched_bases.into_iter().flatten();
182 for index in unmatched_heads {
183 if let Some(base) = remaining_bases.next() {
184 introduced[index] = exceeded_metric_increased(&findings[index], base);
185 }
186 }
187 }
188 introduced
189}
190
191#[must_use]
197pub fn complexity_ledger_keys(
198 findings: &[fallow_output::HealthFinding],
199 root: &Path,
200) -> Vec<String> {
201 let mut seen: FxHashMap<String, usize> = FxHashMap::default();
202 findings
203 .iter()
204 .map(|finding| {
205 let key = health_finding_key(finding, root);
206 let occurrence = seen.entry(key.clone()).or_default();
207 *occurrence += 1;
208 if *occurrence == 1 {
209 key
210 } else {
211 format!("{key}#{occurrence}")
212 }
213 })
214 .collect()
215}
216
217#[cfg(test)]
218mod tests {
219 use std::path::PathBuf;
220
221 use fallow_output::{
222 ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
223 };
224
225 use super::*;
226
227 const MAX: u16 = 15;
228
229 fn root() -> PathBuf {
230 PathBuf::from("/project")
231 }
232
233 fn finding(name: &str, line: u32, cyclomatic: u16, cognitive: u16) -> HealthFinding {
234 HealthFinding::from(ComplexityViolation {
235 path: root().join("src/index.js"),
236 name: name.to_string(),
237 line,
238 col: 0,
239 cyclomatic,
240 cognitive,
241 line_count: 30,
242 param_count: 1,
243 react_hook_count: 0,
244 react_jsx_max_depth: 0,
245 react_prop_count: 0,
246 react_hook_profile: None,
247 exceeded: ExceededThreshold::from_bools(cyclomatic > MAX, cognitive > MAX, false),
248 effective_severity: None,
249 severity: FindingSeverity::High,
250 crap: None,
251 coverage_pct: None,
252 coverage_tier: None,
253 coverage_source: None,
254 inherited_from: None,
255 component_rollup: None,
256 contributions: Vec::new(),
257 effective_thresholds: None,
258 threshold_source: None,
259 })
260 }
261
262 fn baseline(findings: Vec<HealthFinding>) -> ComplexityBaseline {
263 complexity_baseline(
264 &HealthReport {
265 findings,
266 ..HealthReport::default()
267 },
268 &root(),
269 )
270 }
271
272 fn classify(base: Vec<HealthFinding>, head: &[HealthFinding]) -> Vec<bool> {
273 classify_complexity_findings(head, &root(), &baseline(base))
274 }
275
276 #[test]
277 fn identity_key_excludes_category_and_line() {
278 let key = health_finding_key(&finding("hotspot", 4, 17, 16), &root());
279 assert_eq!(key, "complexity:src/index.js:hotspot");
280 }
281
282 #[test]
283 fn issue_cases() {
284 assert_eq!(
286 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 17, 16)]),
287 [false]
288 );
289 assert_eq!(
291 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 18, 17)]),
292 [true]
293 );
294 assert_eq!(
296 classify(vec![finding("f", 1, 18, 17)], &[finding("f", 1, 17, 16)]),
297 [false]
298 );
299 assert_eq!(
301 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 16, 15)]),
302 [false]
303 );
304 assert_eq!(classify(vec![], &[finding("f", 1, 17, 16)]), [true]);
306 }
307
308 #[test]
309 fn only_exceeded_metrics_can_introduce() {
310 assert_eq!(
313 classify(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]),
314 [false]
315 );
316 assert_eq!(
318 classify(vec![finding("f", 1, 20, 14)], &[finding("f", 1, 19, 16)]),
319 [true]
320 );
321 }
322
323 #[test]
324 fn line_shift_keeps_the_match() {
325 assert_eq!(
326 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 40, 17, 16)]),
327 [false]
328 );
329 }
330
331 #[test]
332 fn crap_without_base_value_counts_as_increase() {
333 let mut head = finding("f", 1, 16, 5);
334 head.violation.exceeded = ExceededThreshold::Crap;
335 head.violation.crap = Some(50.0);
336 let mut base = finding("f", 1, 16, 5);
337 base.violation.exceeded = ExceededThreshold::Crap;
338 assert_eq!(classify(vec![base.clone()], &[head.clone()]), [true]);
339 base.violation.crap = Some(60.0);
340 assert_eq!(classify(vec![base], &[head]), [false]);
341 }
342
343 #[test]
344 fn same_named_findings_match_unchanged_values_first() {
345 let base = vec![finding("run", 1, 17, 16), finding("run", 30, 21, 20)];
346 assert_eq!(
348 classify(
349 base.clone(),
350 &[finding("run", 1, 17, 16), finding("run", 30, 21, 20)]
351 ),
352 [false, false]
353 );
354 assert_eq!(
356 classify(base.clone(), &[finding("run", 1, 21, 20)]),
357 [false]
358 );
359 assert_eq!(
361 classify(
362 base.clone(),
363 &[
364 finding("run", 1, 30, 30),
365 finding("run", 20, 17, 16),
366 finding("run", 50, 21, 20)
367 ]
368 ),
369 [true, false, false]
370 );
371 assert_eq!(
373 classify(
374 base,
375 &[finding("run", 1, 18, 17), finding("run", 30, 21, 20)]
376 ),
377 [true, false]
378 );
379 }
380
381 #[test]
382 fn rename_moves_the_base_findings() {
383 let base = baseline(vec![finding("f", 1, 17, 16)]);
384 let renames =
385 FxHashMap::from_iter([("src/index.js".to_string(), "src/moved.js".to_string())]);
386 let remapped = remap_complexity_baseline_for_renames(base, &renames);
387 assert!(remapped.contains_key("complexity:src/moved.js:f"));
388 assert!(!remapped.contains_key("complexity:src/index.js:f"));
389 }
390
391 #[test]
392 fn ledger_keys_count_same_named_findings_separately() {
393 let keys = complexity_ledger_keys(
394 &[finding("run", 1, 17, 16), finding("run", 30, 17, 16)],
395 &root(),
396 );
397 assert_eq!(
398 keys,
399 [
400 "complexity:src/index.js:run",
401 "complexity:src/index.js:run#2"
402 ]
403 );
404 }
405}