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]
195pub fn classify_complexity_findings_against_all<'a>(
196 findings: &[fallow_output::HealthFinding],
197 root: &Path,
198 bases: impl IntoIterator<Item = &'a ComplexityBaseline>,
199) -> Vec<bool> {
200 let mut introduced = vec![true; findings.len()];
201 for base in bases {
202 for (flag, against) in introduced
203 .iter_mut()
204 .zip(classify_complexity_findings(findings, root, base))
205 {
206 *flag &= against;
207 }
208 }
209 introduced
210}
211
212#[must_use]
218pub fn complexity_ledger_keys(
219 findings: &[fallow_output::HealthFinding],
220 root: &Path,
221) -> Vec<String> {
222 let mut seen: FxHashMap<String, usize> = FxHashMap::default();
223 findings
224 .iter()
225 .map(|finding| {
226 let key = health_finding_key(finding, root);
227 let occurrence = seen.entry(key.clone()).or_default();
228 *occurrence += 1;
229 if *occurrence == 1 {
230 key
231 } else {
232 format!("{key}#{occurrence}")
233 }
234 })
235 .collect()
236}
237
238#[cfg(test)]
239mod tests {
240 use std::path::PathBuf;
241
242 use fallow_output::{
243 ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
244 };
245
246 use super::*;
247
248 const MAX: u16 = 15;
249
250 fn root() -> PathBuf {
251 PathBuf::from("/project")
252 }
253
254 fn finding(name: &str, line: u32, cyclomatic: u16, cognitive: u16) -> HealthFinding {
255 HealthFinding::from(ComplexityViolation {
256 path: root().join("src/index.js"),
257 name: name.to_string(),
258 line,
259 col: 0,
260 cyclomatic,
261 cognitive,
262 line_count: 30,
263 param_count: 1,
264 react_hook_count: 0,
265 react_jsx_max_depth: 0,
266 react_prop_count: 0,
267 react_hook_profile: None,
268 exceeded: ExceededThreshold::from_bools(cyclomatic > MAX, cognitive > MAX, false),
269 effective_severity: None,
270 severity: FindingSeverity::High,
271 crap: None,
272 mutation: None,
273 coverage_pct: None,
274 coverage_tier: None,
275 coverage_source: None,
276 inherited_from: None,
277 component_rollup: None,
278 contributions: Vec::new(),
279 effective_thresholds: None,
280 threshold_source: None,
281 })
282 }
283
284 fn baseline(findings: Vec<HealthFinding>) -> ComplexityBaseline {
285 complexity_baseline(
286 &HealthReport {
287 findings,
288 ..HealthReport::default()
289 },
290 &root(),
291 )
292 }
293
294 fn classify(base: Vec<HealthFinding>, head: &[HealthFinding]) -> Vec<bool> {
295 classify_complexity_findings(head, &root(), &baseline(base))
296 }
297
298 #[test]
299 fn identity_key_excludes_category_and_line() {
300 let key = health_finding_key(&finding("hotspot", 4, 17, 16), &root());
301 assert_eq!(key, "complexity:src/index.js:hotspot");
302 }
303
304 #[test]
305 fn issue_cases() {
306 assert_eq!(
308 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 17, 16)]),
309 [false]
310 );
311 assert_eq!(
313 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 18, 17)]),
314 [true]
315 );
316 assert_eq!(
318 classify(vec![finding("f", 1, 18, 17)], &[finding("f", 1, 17, 16)]),
319 [false]
320 );
321 assert_eq!(
323 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 16, 15)]),
324 [false]
325 );
326 assert_eq!(classify(vec![], &[finding("f", 1, 17, 16)]), [true]);
328 }
329
330 #[test]
331 fn only_exceeded_metrics_can_introduce() {
332 assert_eq!(
335 classify(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]),
336 [false]
337 );
338 assert_eq!(
340 classify(vec![finding("f", 1, 20, 14)], &[finding("f", 1, 19, 16)]),
341 [true]
342 );
343 }
344
345 #[test]
346 fn a_finding_that_any_base_has_is_inherited() {
347 let primary = baseline(vec![]);
348 let merged = baseline(vec![finding("f", 1, 17, 16)]);
349 let head = [finding("f", 1, 17, 16), finding("g", 9, 17, 16)];
350 assert_eq!(
351 classify_complexity_findings_against_all(&head, &root(), [&primary, &merged]),
352 [false, true]
353 );
354 assert_eq!(
356 classify_complexity_findings_against_all(
357 &[finding("f", 1, 18, 17)],
358 &root(),
359 [&baseline(vec![finding("f", 1, 17, 16)]), &merged]
360 ),
361 [true]
362 );
363 }
364
365 #[test]
366 fn line_shift_keeps_the_match() {
367 assert_eq!(
368 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 40, 17, 16)]),
369 [false]
370 );
371 }
372
373 #[test]
374 fn crap_without_base_value_counts_as_increase() {
375 let mut head = finding("f", 1, 16, 5);
376 head.violation.exceeded = ExceededThreshold::Crap;
377 head.violation.crap = Some(50.0);
378 let mut base = finding("f", 1, 16, 5);
379 base.violation.exceeded = ExceededThreshold::Crap;
380 assert_eq!(classify(vec![base.clone()], &[head.clone()]), [true]);
381 base.violation.crap = Some(60.0);
382 assert_eq!(classify(vec![base], &[head]), [false]);
383 }
384
385 #[test]
386 fn same_named_findings_match_unchanged_values_first() {
387 let base = vec![finding("run", 1, 17, 16), finding("run", 30, 21, 20)];
388 assert_eq!(
390 classify(
391 base.clone(),
392 &[finding("run", 1, 17, 16), finding("run", 30, 21, 20)]
393 ),
394 [false, false]
395 );
396 assert_eq!(
398 classify(base.clone(), &[finding("run", 1, 21, 20)]),
399 [false]
400 );
401 assert_eq!(
403 classify(
404 base.clone(),
405 &[
406 finding("run", 1, 30, 30),
407 finding("run", 20, 17, 16),
408 finding("run", 50, 21, 20)
409 ]
410 ),
411 [true, false, false]
412 );
413 assert_eq!(
415 classify(
416 base,
417 &[finding("run", 1, 18, 17), finding("run", 30, 21, 20)]
418 ),
419 [true, false]
420 );
421 }
422
423 #[test]
424 fn rename_moves_the_base_findings() {
425 let base = baseline(vec![finding("f", 1, 17, 16)]);
426 let renames =
427 FxHashMap::from_iter([("src/index.js".to_string(), "src/moved.js".to_string())]);
428 let remapped = remap_complexity_baseline_for_renames(base, &renames);
429 assert!(remapped.contains_key("complexity:src/moved.js:f"));
430 assert!(!remapped.contains_key("complexity:src/index.js:f"));
431 }
432
433 #[test]
434 fn ledger_keys_count_same_named_findings_separately() {
435 let keys = complexity_ledger_keys(
436 &[finding("run", 1, 17, 16), finding("run", 30, 17, 16)],
437 &root(),
438 );
439 assert_eq!(
440 keys,
441 [
442 "complexity:src/index.js:run",
443 "complexity:src/index.js:run#2"
444 ]
445 );
446 }
447}