1use serde::Serialize;
2
3use super::delta::{DeltaSummary, FunctionChange};
4use super::types::{
5 AnalysisResult, AnalysisSummary, CrapScore, FunctionIdentity, FunctionVerdict,
6 RiskDistribution, RiskLevel,
7};
8
9pub fn compute_summary<'a, I>(verdicts: I) -> AnalysisSummary
17where
18 I: IntoIterator<Item = &'a FunctionVerdict>,
19{
20 let mut distribution = RiskDistribution {
21 low: 0,
22 acceptable: 0,
23 moderate: 0,
24 high: 0,
25 };
26
27 let mut scores: Vec<f64> = Vec::new();
28 let mut complexities: Vec<u32> = Vec::new();
29 let mut finite_coverages: Vec<f64> = Vec::new();
30 let mut files: std::collections::HashSet<&'a String> = std::collections::HashSet::new();
31 let mut exceeding: usize = 0;
32 let mut max_crap = None;
33 let mut worst_function = None;
34 let mut max_complexity: u32 = 0;
35
36 for v in verdicts {
37 let score = v.scored.crap.value;
38 scores.push(score);
39 complexities.push(v.scored.complexity);
40 let cov = v.scored.coverage_percent;
41 if cov.is_finite() {
42 finite_coverages.push(cov);
43 }
44 files.insert(&v.scored.identity.file_path);
45
46 if v.exceeds {
47 exceeding += 1;
48 }
49
50 match v.scored.crap.risk_level {
51 RiskLevel::Low => distribution.low += 1,
52 RiskLevel::Acceptable => distribution.acceptable += 1,
53 RiskLevel::Moderate => distribution.moderate += 1,
54 RiskLevel::High => distribution.high += 1,
55 }
56
57 if max_crap.is_none() || score > max_crap.unwrap_or(0.0) {
58 max_crap = Some(score);
59 worst_function = Some(v.scored.identity.clone());
60 }
61 if v.scored.complexity > max_complexity {
62 max_complexity = v.scored.complexity;
63 }
64 }
65
66 let total_functions = scores.len();
67 scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
68 complexities.sort_unstable();
69 finite_coverages.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
70
71 let average_crap = mean_f64(&scores);
72 let median_crap = median_f64_sorted(&scores);
73
74 let average_complexity = mean_u32(&complexities);
75 let median_complexity = median_u32_sorted(&complexities);
76
77 let min_coverage = finite_coverages.first().copied().unwrap_or(0.0);
78 let average_coverage = mean_f64(&finite_coverages);
79 let median_coverage = median_f64_sorted(&finite_coverages);
80
81 AnalysisSummary {
82 total_functions,
83 total_files: files.len(),
84 exceeding_threshold: exceeding,
85 average_crap,
86 median_crap,
87 max_crap: max_crap.map(super::crap::classify_risk).map(|risk_level| {
88 super::types::CrapScore {
89 value: max_crap.unwrap(),
90 risk_level,
91 }
92 }),
93 worst_function,
94 distribution,
95 max_complexity,
96 average_complexity,
97 median_complexity,
98 min_coverage,
99 average_coverage,
100 median_coverage,
101 }
102}
103
104fn mean_f64(values: &[f64]) -> f64 {
105 if values.is_empty() {
106 return 0.0;
107 }
108 values.iter().sum::<f64>() / values.len() as f64
109}
110
111fn mean_u32(values: &[u32]) -> f64 {
112 if values.is_empty() {
113 return 0.0;
114 }
115 values.iter().map(|v| *v as f64).sum::<f64>() / values.len() as f64
116}
117
118fn median_f64_sorted(sorted: &[f64]) -> f64 {
119 if sorted.is_empty() {
120 return 0.0;
121 }
122 let n = sorted.len();
123 if n.is_multiple_of(2) {
124 (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
125 } else {
126 sorted[n / 2]
127 }
128}
129
130fn median_u32_sorted(sorted: &[u32]) -> f64 {
131 if sorted.is_empty() {
132 return 0.0;
133 }
134 let n = sorted.len();
135 if n.is_multiple_of(2) {
136 (sorted[n / 2 - 1] as f64 + sorted[n / 2] as f64) / 2.0
137 } else {
138 sorted[n / 2] as f64
139 }
140}
141
142#[derive(Debug, Clone, Serialize)]
150pub struct FileSummary {
151 pub file_path: String,
152 pub function_count: usize,
153 pub exceeding_count: usize,
154 pub average_crap: f64,
155 pub median_crap: f64,
156 pub max_crap: Option<CrapScore>,
157 pub worst_function: Option<FunctionIdentity>,
158 pub distribution: RiskDistribution,
159 pub average_coverage: f64,
164 pub max_complexity: u32,
169}
170
171pub fn compute_file_summaries<'a, I>(verdicts: I) -> Vec<FileSummary>
179where
180 I: IntoIterator<Item = &'a FunctionVerdict>,
181{
182 let mut order: Vec<&'a String> = Vec::new();
185 let mut buckets: std::collections::HashMap<&'a String, Vec<&'a FunctionVerdict>> =
186 std::collections::HashMap::new();
187
188 for v in verdicts {
189 let path = &v.scored.identity.file_path;
190 if !buckets.contains_key(path) {
191 order.push(path);
192 }
193 buckets.entry(path).or_default().push(v);
194 }
195
196 order
197 .into_iter()
198 .map(|path| {
199 let bucket = buckets
200 .remove(path)
201 .expect("bucket present for ordered key");
202 file_summary_for(path.clone(), &bucket)
203 })
204 .collect()
205}
206
207struct FileAcc {
213 sum_crap: f64,
214 max_crap_value: Option<f64>,
215 worst_function: Option<FunctionIdentity>,
216 finite_scores: Vec<f64>,
217 sum_finite_coverage: f64,
218 finite_coverage_count: usize,
219 max_complexity: u32,
220 exceeding_count: usize,
221 distribution: RiskDistribution,
222}
223
224impl FileAcc {
225 fn with_capacity(n: usize) -> Self {
226 Self {
227 sum_crap: 0.0,
228 max_crap_value: None,
229 worst_function: None,
230 finite_scores: Vec::with_capacity(n),
231 sum_finite_coverage: 0.0,
232 finite_coverage_count: 0,
233 max_complexity: 0,
234 exceeding_count: 0,
235 distribution: RiskDistribution {
236 low: 0,
237 acceptable: 0,
238 moderate: 0,
239 high: 0,
240 },
241 }
242 }
243
244 fn fold(&mut self, v: &FunctionVerdict) {
245 let score = v.scored.crap.value;
246 self.sum_crap += score;
253 if score.is_finite() {
254 self.finite_scores.push(score);
255 }
256 if v.exceeds {
257 self.exceeding_count += 1;
258 }
259 bump_distribution(&mut self.distribution, v.scored.crap.risk_level);
260 if beats(self.max_crap_value, score) {
261 self.max_crap_value = Some(score);
262 self.worst_function = Some(v.scored.identity.clone());
263 }
264 let cov = v.scored.coverage_percent;
265 if cov.is_finite() {
266 self.sum_finite_coverage += cov;
267 self.finite_coverage_count += 1;
268 }
269 if v.scored.complexity > self.max_complexity {
273 self.max_complexity = v.scored.complexity;
274 }
275 }
276}
277
278fn bump_distribution(d: &mut RiskDistribution, level: RiskLevel) {
279 match level {
280 RiskLevel::Low => d.low += 1,
281 RiskLevel::Acceptable => d.acceptable += 1,
282 RiskLevel::Moderate => d.moderate += 1,
283 RiskLevel::High => d.high += 1,
284 }
285}
286
287fn beats(curr: Option<f64>, score: f64) -> bool {
290 match curr {
291 None => true,
292 Some(c) => score > c,
293 }
294}
295
296fn safe_avg(sum: f64, count: usize) -> f64 {
297 if count > 0 { sum / count as f64 } else { 0.0 }
298}
299
300fn file_summary_for(file_path: String, verdicts: &[&FunctionVerdict]) -> FileSummary {
301 let function_count = verdicts.len();
302 let mut acc = FileAcc::with_capacity(function_count);
303 for v in verdicts {
304 acc.fold(v);
305 }
306 FileSummary {
307 file_path,
308 function_count,
309 exceeding_count: acc.exceeding_count,
310 average_crap: safe_avg(acc.sum_crap, function_count),
311 median_crap: median_of(&mut acc.finite_scores),
312 max_crap: acc.max_crap_value.map(|value| CrapScore {
313 value,
314 risk_level: super::crap::classify_risk(value),
315 }),
316 worst_function: acc.worst_function,
317 distribution: acc.distribution,
318 average_coverage: safe_avg(acc.sum_finite_coverage, acc.finite_coverage_count),
319 max_complexity: acc.max_complexity,
320 }
321}
322
323fn median_of(scores: &mut [f64]) -> f64 {
327 if scores.is_empty() {
328 return 0.0;
329 }
330 scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
331 let n = scores.len();
332 if n.is_multiple_of(2) {
333 (scores[n / 2 - 1] + scores[n / 2]) / 2.0
334 } else {
335 scores[n / 2]
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
345#[serde(rename_all = "PascalCase")]
346pub enum CrapDeltaStatus {
347 Green,
348 Yellow,
349 Red,
350}
351
352impl CrapDeltaStatus {
353 pub fn as_wire_str(&self) -> &'static str {
359 match self {
360 Self::Green => "Green",
361 Self::Yellow => "Yellow",
362 Self::Red => "Red",
363 }
364 }
365}
366
367#[derive(Debug, Clone, Serialize)]
374pub struct CrapDeltaRowData {
375 pub status: CrapDeltaStatus,
376 pub threshold: u32,
377 pub delta_count: i32,
378 pub delta_text: String,
379 pub failure_detail_md: Option<String>,
380}
381
382pub fn project_crap_delta_row(
393 current: &AnalysisResult,
394 baseline: Option<&AnalysisResult>,
395 delta: Option<(&DeltaSummary, &[FunctionChange])>,
396 threshold: u32,
397) -> CrapDeltaRowData {
398 let current_count = current.summary.exceeding_threshold;
399 let baseline_count = baseline.map_or(0, |b| b.summary.exceeding_threshold);
400 let delta_count = current_count as i32 - baseline_count as i32;
401
402 let (status, delta_text, failure_detail_md) = match delta {
403 Some((delta_summary, changes)) => resolve_with_delta(
404 baseline_count,
405 current_count,
406 delta_count,
407 delta_summary,
408 changes,
409 threshold,
410 ),
411 None => resolve_no_baseline(current, current_count, threshold),
412 };
413
414 CrapDeltaRowData {
415 status,
416 threshold,
417 delta_count,
418 delta_text,
419 failure_detail_md,
420 }
421}
422
423fn resolve_with_delta(
424 baseline_count: usize,
425 current_count: usize,
426 delta_count: i32,
427 delta_summary: &DeltaSummary,
428 changes: &[FunctionChange],
429 threshold: u32,
430) -> (CrapDeltaStatus, String, Option<String>) {
431 if delta_summary.new_violations > 0 {
432 let detail = render_delta_failure_detail(changes, threshold);
433 let text = format_delta_text_red(baseline_count, current_count, delta_count);
434 (CrapDeltaStatus::Red, text, Some(detail))
435 } else if delta_summary.regressions > 0 {
436 let text =
437 format!("{baseline_count} → {current_count} (regressions on existing functions)");
438 (CrapDeltaStatus::Yellow, text, None)
439 } else {
440 let text = format_delta_text_green(baseline_count, current_count, delta_count);
441 (CrapDeltaStatus::Green, text, None)
442 }
443}
444
445fn resolve_no_baseline(
446 current: &AnalysisResult,
447 current_count: usize,
448 threshold: u32,
449) -> (CrapDeltaStatus, String, Option<String>) {
450 if current_count == 0 {
451 (
452 CrapDeltaStatus::Green,
453 "0 over threshold (no baseline)".to_string(),
454 None,
455 )
456 } else {
457 let detail = render_no_baseline_failure_detail(current, threshold);
458 let text = format!("{current_count} over threshold (no baseline)");
459 (CrapDeltaStatus::Red, text, Some(detail))
460 }
461}
462
463fn format_delta_text_red(baseline: usize, current: usize, delta: i32) -> String {
464 format!("{baseline} → {current} ({delta:+})")
465}
466
467fn format_delta_text_green(baseline: usize, current: usize, delta: i32) -> String {
468 if delta == 0 {
469 format!("{baseline} → {current}")
470 } else {
471 format!("{baseline} → {current} ({delta:+})")
472 }
473}
474
475fn render_delta_failure_detail(changes: &[FunctionChange], threshold: u32) -> String {
476 let mut violators: Vec<NewViolator> = changes
477 .iter()
478 .filter_map(NewViolator::from_change)
479 .collect();
480 violators.sort_by(|a, b| {
481 b.current_crap
482 .partial_cmp(&a.current_crap)
483 .unwrap_or(std::cmp::Ordering::Equal)
484 });
485
486 let mut out = format!("**New CRAP threshold violations (>{threshold}):**\n");
487 for v in &violators {
488 let baseline_str = match v.baseline_crap {
489 Some(b) => format!("was {b:.1}"),
490 None => "newly added".to_string(),
491 };
492 out.push_str(&format!(
493 "- `{name}` — `{file}:{line}` — CRAP {current:.1} ({baseline_str})\n",
494 name = v.qualified_name,
495 file = v.file_path,
496 line = v.line,
497 current = v.current_crap,
498 ));
499 }
500 out
501}
502
503fn render_no_baseline_failure_detail(result: &AnalysisResult, threshold: u32) -> String {
504 let mut violators: Vec<&FunctionVerdict> =
505 result.functions.iter().filter(|v| v.exceeds).collect();
506 violators.sort_by(|a, b| {
507 b.scored
508 .crap
509 .value
510 .partial_cmp(&a.scored.crap.value)
511 .unwrap_or(std::cmp::Ordering::Equal)
512 });
513
514 let mut out = format!("**Functions over CRAP threshold (>{threshold}):**\n");
515 for v in &violators {
516 out.push_str(&format!(
517 "- `{name}` — `{file}:{line}` — CRAP {crap:.1}\n",
518 name = v.scored.identity.qualified_name,
519 file = v.scored.identity.file_path,
520 line = v.scored.identity.span.start_line,
521 crap = v.scored.crap.value,
522 ));
523 }
524 out
525}
526
527struct NewViolator {
528 qualified_name: String,
529 file_path: String,
530 line: usize,
531 current_crap: f64,
532 baseline_crap: Option<f64>,
533}
534
535impl NewViolator {
536 fn from_change(change: &FunctionChange) -> Option<Self> {
537 match change {
538 FunctionChange::Added { current } if current.exceeds => Some(Self {
539 qualified_name: current.scored.identity.qualified_name.clone(),
540 file_path: current.scored.identity.file_path.clone(),
541 line: current.scored.identity.span.start_line,
542 current_crap: current.scored.crap.value,
543 baseline_crap: None,
544 }),
545 FunctionChange::Modified { baseline, current }
546 if !baseline.exceeds && current.exceeds =>
547 {
548 Some(Self {
549 qualified_name: current.scored.identity.qualified_name.clone(),
550 file_path: current.scored.identity.file_path.clone(),
551 line: current.scored.identity.span.start_line,
552 current_crap: current.scored.crap.value,
553 baseline_crap: Some(baseline.scored.crap.value),
554 })
555 }
556 _ => None,
557 }
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use crate::domain::types::{
565 ComplexityMetric, CrapScore, FunctionIdentity, ScoredFunction, SourceSpan,
566 };
567
568 fn make_verdict(file: &str, name: &str, crap_value: f64, threshold: f64) -> FunctionVerdict {
569 let risk_level = super::super::crap::classify_risk(crap_value);
570 FunctionVerdict {
571 scored: ScoredFunction {
572 identity: FunctionIdentity {
573 file_path: file.to_string(),
574 qualified_name: name.to_string(),
575 span: SourceSpan {
576 start_line: 1,
577 end_line: 10,
578 start_column: 0,
579 end_column: 0,
580 },
581 },
582 complexity: 1,
583 complexity_metric: ComplexityMetric::Cognitive,
584 coverage_percent: 100.0,
585 crap: CrapScore {
586 value: crap_value,
587 risk_level,
588 },
589 contributors: vec![],
590 },
591 threshold,
592 exceeds: crap_value > threshold,
593 diagnostic: None,
594 }
595 }
596
597 #[test]
598 fn empty_verdicts() {
599 let summary = compute_summary(&[]);
600 assert_eq!(summary.total_functions, 0);
601 assert_eq!(summary.total_files, 0);
602 assert_eq!(summary.exceeding_threshold, 0);
603 assert_eq!(summary.average_crap, 0.0);
604 assert_eq!(summary.median_crap, 0.0);
605 assert!(summary.max_crap.is_none());
606 assert!(summary.worst_function.is_none());
607 assert_eq!(summary.distribution.low, 0);
608 assert_eq!(summary.distribution.acceptable, 0);
609 assert_eq!(summary.distribution.moderate, 0);
610 assert_eq!(summary.distribution.high, 0);
611 }
612
613 #[test]
614 fn single_verdict() {
615 let v = make_verdict("a.rs", "foo", 3.0, 30.0);
616 let summary = compute_summary(&[v]);
617 assert_eq!(summary.total_functions, 1);
618 assert_eq!(summary.total_files, 1);
619 assert_eq!(summary.exceeding_threshold, 0);
620 assert_eq!(summary.average_crap, 3.0);
621 assert_eq!(summary.median_crap, 3.0);
622 assert_eq!(summary.max_crap.unwrap().value, 3.0);
623 assert_eq!(
624 summary.worst_function.as_ref().unwrap().qualified_name,
625 "foo"
626 );
627 }
628
629 #[test]
630 fn odd_count_median() {
631 let verdicts = vec![
632 make_verdict("a.rs", "a", 1.0, 30.0),
633 make_verdict("a.rs", "b", 5.0, 30.0),
634 make_verdict("a.rs", "c", 9.0, 30.0),
635 ];
636 let summary = compute_summary(&verdicts);
637 assert_eq!(summary.median_crap, 5.0);
638 }
639
640 #[test]
641 fn even_count_median() {
642 let verdicts = vec![
643 make_verdict("a.rs", "a", 2.0, 30.0),
644 make_verdict("a.rs", "b", 4.0, 30.0),
645 make_verdict("a.rs", "c", 6.0, 30.0),
646 make_verdict("a.rs", "d", 8.0, 30.0),
647 ];
648 let summary = compute_summary(&verdicts);
649 assert_eq!(summary.median_crap, 5.0);
651 }
652
653 #[test]
654 fn distribution_counting() {
655 let verdicts = vec![
656 make_verdict("a.rs", "low", 2.0, 30.0), make_verdict("a.rs", "acceptable", 6.0, 30.0), make_verdict("a.rs", "moderate", 15.0, 30.0), make_verdict("a.rs", "high", 50.0, 30.0), ];
661 let summary = compute_summary(&verdicts);
662 assert_eq!(summary.distribution.low, 1);
663 assert_eq!(summary.distribution.acceptable, 1);
664 assert_eq!(summary.distribution.moderate, 1);
665 assert_eq!(summary.distribution.high, 1);
666 }
667
668 #[test]
669 fn max_crap_and_worst_function() {
670 let verdicts = vec![
671 make_verdict("a.rs", "small", 2.0, 30.0),
672 make_verdict("b.rs", "big", 50.0, 30.0),
673 make_verdict("c.rs", "medium", 10.0, 30.0),
674 ];
675 let summary = compute_summary(&verdicts);
676 assert_eq!(summary.max_crap.unwrap().value, 50.0);
677 assert_eq!(
678 summary.worst_function.as_ref().unwrap().qualified_name,
679 "big"
680 );
681 }
682
683 #[test]
684 fn file_deduplication() {
685 let verdicts = vec![
686 make_verdict("a.rs", "foo", 2.0, 30.0),
687 make_verdict("a.rs", "bar", 3.0, 30.0),
688 make_verdict("b.rs", "baz", 4.0, 30.0),
689 ];
690 let summary = compute_summary(&verdicts);
691 assert_eq!(summary.total_functions, 3);
692 assert_eq!(summary.total_files, 2);
693 }
694
695 #[test]
696 fn exceeding_threshold_count() {
697 let verdicts = vec![
698 make_verdict("a.rs", "ok", 5.0, 10.0), make_verdict("a.rs", "bad", 15.0, 10.0), make_verdict("a.rs", "worse", 50.0, 10.0), ];
702 let summary = compute_summary(&verdicts);
703 assert_eq!(summary.exceeding_threshold, 2);
704 }
705
706 #[test]
707 fn average_calculation() {
708 let verdicts = vec![
709 make_verdict("a.rs", "a", 3.0, 30.0),
710 make_verdict("a.rs", "b", 6.0, 30.0),
711 make_verdict("a.rs", "c", 9.0, 30.0),
712 ];
713 let summary = compute_summary(&verdicts);
714 assert_eq!(summary.average_crap, 6.0);
715 }
716
717 #[test]
718 fn tied_scores_first_wins_worst_function() {
719 let verdicts = vec![
720 make_verdict("a.rs", "first", 10.0, 30.0),
721 make_verdict("a.rs", "second", 10.0, 30.0),
722 ];
723 let summary = compute_summary(&verdicts);
724 assert_eq!(
725 summary.worst_function.as_ref().unwrap().qualified_name,
726 "first"
727 );
728 }
729}
730
731#[cfg(test)]
732mod file_summary_tests {
733 use super::*;
734 use crate::domain::types::{
735 ComplexityMetric, CrapScore, FunctionIdentity, ScoredFunction, SourceSpan,
736 };
737
738 fn vrd(file: &str, name: &str, crap_value: f64, threshold: f64) -> FunctionVerdict {
739 let risk_level = super::super::crap::classify_risk(crap_value);
740 FunctionVerdict {
741 scored: ScoredFunction {
742 identity: FunctionIdentity {
743 file_path: file.to_string(),
744 qualified_name: name.to_string(),
745 span: SourceSpan {
746 start_line: 1,
747 end_line: 10,
748 start_column: 0,
749 end_column: 0,
750 },
751 },
752 complexity: 1,
753 complexity_metric: ComplexityMetric::Cognitive,
754 coverage_percent: 100.0,
755 crap: CrapScore {
756 value: crap_value,
757 risk_level,
758 },
759 contributors: vec![],
760 },
761 threshold,
762 exceeds: crap_value > threshold,
763 diagnostic: None,
764 }
765 }
766
767 #[test]
768 fn empty_input_returns_empty_vec() {
769 let result = compute_file_summaries(&[]);
771 assert!(result.is_empty());
772 }
773
774 #[test]
775 fn single_file_single_function() {
776 let v = vrd("a.rs", "foo", 3.0, 25.0);
777 let summaries = compute_file_summaries(&[v]);
778 assert_eq!(summaries.len(), 1);
779 let f = &summaries[0];
780 assert_eq!(f.file_path, "a.rs");
781 assert_eq!(f.function_count, 1);
782 assert_eq!(f.exceeding_count, 0);
783 assert_eq!(f.average_crap, 3.0);
784 assert_eq!(f.median_crap, 3.0);
785 assert_eq!(f.max_crap.unwrap().value, 3.0);
786 assert_eq!(f.worst_function.as_ref().unwrap().qualified_name, "foo");
787 }
788
789 #[test]
790 fn partition_completeness() {
791 let verdicts = vec![
793 vrd("a.rs", "a1", 1.0, 25.0),
794 vrd("a.rs", "a2", 2.0, 25.0),
795 vrd("b.rs", "b1", 30.0, 25.0),
796 vrd("c.rs", "c1", 4.0, 25.0),
797 ];
798 let summaries = compute_file_summaries(&verdicts);
799 let total: usize = summaries.iter().map(|f| f.function_count).sum();
800 assert_eq!(total, verdicts.len());
801 }
802
803 #[test]
804 fn distinct_files_are_grouped() {
805 let verdicts = vec![
807 vrd("a.rs", "a1", 1.0, 25.0),
808 vrd("a.rs", "a2", 2.0, 25.0),
809 vrd("b.rs", "b1", 30.0, 25.0),
810 vrd("c.rs", "c1", 4.0, 25.0),
811 ];
812 let summaries = compute_file_summaries(&verdicts);
813 assert_eq!(summaries.len(), 3);
814 let mut paths: Vec<&str> = summaries.iter().map(|f| f.file_path.as_str()).collect();
815 paths.sort();
816 assert_eq!(paths, vec!["a.rs", "b.rs", "c.rs"]);
817 }
818
819 #[test]
820 fn exceeding_count_aggregates_per_file() {
821 let verdicts = vec![
823 vrd("a.rs", "ok", 5.0, 10.0),
824 vrd("a.rs", "bad1", 15.0, 10.0),
825 vrd("a.rs", "bad2", 50.0, 10.0),
826 vrd("b.rs", "ok2", 3.0, 10.0),
827 ];
828 let summaries = compute_file_summaries(&verdicts);
829 let a = summaries.iter().find(|f| f.file_path == "a.rs").unwrap();
830 let b = summaries.iter().find(|f| f.file_path == "b.rs").unwrap();
831 assert_eq!(a.exceeding_count, 2);
832 assert_eq!(b.exceeding_count, 0);
833 }
834
835 #[test]
836 fn exceeding_count_sum_matches_total() {
837 let verdicts = vec![
839 vrd("a.rs", "ok", 5.0, 10.0),
840 vrd("a.rs", "bad1", 15.0, 10.0),
841 vrd("b.rs", "bad2", 50.0, 10.0),
842 vrd("c.rs", "ok2", 3.0, 10.0),
843 ];
844 let summaries = compute_file_summaries(&verdicts);
845 let sum: usize = summaries.iter().map(|f| f.exceeding_count).sum();
846 let manual = verdicts.iter().filter(|v| v.exceeds).count();
847 assert_eq!(sum, manual);
848 }
849
850 #[test]
851 fn max_crap_per_file_correct() {
852 let verdicts = vec![
854 vrd("a.rs", "low", 5.0, 25.0),
855 vrd("a.rs", "high", 50.0, 25.0),
856 vrd("b.rs", "med", 12.0, 25.0),
857 ];
858 let summaries = compute_file_summaries(&verdicts);
859 let a = summaries.iter().find(|f| f.file_path == "a.rs").unwrap();
860 let b = summaries.iter().find(|f| f.file_path == "b.rs").unwrap();
861 assert_eq!(a.max_crap.unwrap().value, 50.0);
862 assert_eq!(a.worst_function.as_ref().unwrap().qualified_name, "high");
863 assert_eq!(b.max_crap.unwrap().value, 12.0);
864 }
865
866 #[test]
867 fn average_crap_per_file_correct() {
868 let verdicts = vec![
870 vrd("a.rs", "a1", 4.0, 25.0),
871 vrd("a.rs", "a2", 6.0, 25.0),
872 vrd("a.rs", "a3", 14.0, 25.0),
873 vrd("b.rs", "b1", 3.0, 25.0),
874 ];
875 let summaries = compute_file_summaries(&verdicts);
876 let a = summaries.iter().find(|f| f.file_path == "a.rs").unwrap();
877 let b = summaries.iter().find(|f| f.file_path == "b.rs").unwrap();
878 assert!((a.average_crap - 8.0).abs() < 1e-9);
880 assert!((b.average_crap - 3.0).abs() < 1e-9);
881 }
882
883 #[test]
884 fn median_per_file_odd_and_even() {
885 let verdicts = vec![
886 vrd("a.rs", "a1", 1.0, 25.0),
887 vrd("a.rs", "a2", 5.0, 25.0),
888 vrd("a.rs", "a3", 9.0, 25.0),
889 vrd("b.rs", "b1", 2.0, 25.0),
890 vrd("b.rs", "b2", 8.0, 25.0),
891 ];
892 let summaries = compute_file_summaries(&verdicts);
893 let a = summaries.iter().find(|f| f.file_path == "a.rs").unwrap();
894 let b = summaries.iter().find(|f| f.file_path == "b.rs").unwrap();
895 assert_eq!(a.median_crap, 5.0);
896 assert_eq!(b.median_crap, 5.0); }
898
899 #[test]
900 fn distribution_per_file() {
901 let verdicts = vec![
902 vrd("a.rs", "low", 2.0, 25.0), vrd("a.rs", "acceptable", 6.0, 25.0), vrd("a.rs", "moderate", 15.0, 25.0), vrd("a.rs", "high", 50.0, 25.0), ];
907 let summaries = compute_file_summaries(&verdicts);
908 assert_eq!(summaries.len(), 1);
909 let d = &summaries[0].distribution;
910 assert_eq!(d.low, 1);
911 assert_eq!(d.acceptable, 1);
912 assert_eq!(d.moderate, 1);
913 assert_eq!(d.high, 1);
914 }
915
916 #[test]
917 fn nan_coverage_does_not_panic() {
918 let v = FunctionVerdict {
921 scored: ScoredFunction {
922 identity: FunctionIdentity {
923 file_path: "a.rs".to_string(),
924 qualified_name: "f".to_string(),
925 span: SourceSpan {
926 start_line: 1,
927 end_line: 10,
928 start_column: 0,
929 end_column: 0,
930 },
931 },
932 complexity: 1,
933 complexity_metric: ComplexityMetric::Cognitive,
934 coverage_percent: f64::NAN,
935 crap: CrapScore {
936 value: 5.0,
937 risk_level: RiskLevel::Low,
938 },
939 contributors: vec![],
940 },
941 threshold: 25.0,
942 exceeds: false,
943 diagnostic: None,
944 };
945 let summaries = compute_file_summaries(&[v]);
946 assert_eq!(summaries.len(), 1);
947 assert_eq!(summaries[0].function_count, 1);
948 assert_eq!(summaries[0].average_coverage, 0.0);
951 assert!(!summaries[0].average_coverage.is_nan());
952 }
953
954 #[test]
955 fn file_summary_for_empty_input_does_not_divide_by_zero() {
956 let summary = super::file_summary_for("empty.rs".to_string(), &[]);
962 assert_eq!(summary.function_count, 0);
963 assert_eq!(summary.average_crap, 0.0);
964 assert_eq!(summary.average_coverage, 0.0);
965 assert!(!summary.average_crap.is_nan());
966 assert!(!summary.average_coverage.is_nan());
967 }
968
969 #[test]
970 fn average_coverage_excludes_nan() {
971 let v_finite_a = vrd("a.rs", "f1", 1.0, 25.0); let mut v_finite_b = vrd("a.rs", "f2", 1.0, 25.0);
974 v_finite_b.scored.coverage_percent = 50.0;
975 let mut v_nan = vrd("a.rs", "f3", 1.0, 25.0);
976 v_nan.scored.coverage_percent = f64::NAN;
977 let summaries = compute_file_summaries(&[v_finite_a, v_finite_b, v_nan]);
978 assert!((summaries[0].average_coverage - 75.0).abs() < 1e-9);
979 }
980
981 #[test]
982 fn max_complexity_per_file() {
983 let mut v1 = vrd("a.rs", "small", 1.0, 25.0);
984 v1.scored.complexity = 3;
985 let mut v2 = vrd("a.rs", "big", 1.0, 25.0);
986 v2.scored.complexity = 17;
987 let mut v3 = vrd("a.rs", "med", 1.0, 25.0);
988 v3.scored.complexity = 8;
989 let summaries = compute_file_summaries(&[v1, v2, v3]);
990 assert_eq!(summaries[0].max_complexity, 17);
991 }
992
993 #[test]
994 fn tied_max_first_wins() {
995 let verdicts = vec![
997 vrd("a.rs", "first", 10.0, 25.0),
998 vrd("a.rs", "second", 10.0, 25.0),
999 ];
1000 let summaries = compute_file_summaries(&verdicts);
1001 assert_eq!(
1002 summaries[0].worst_function.as_ref().unwrap().qualified_name,
1003 "first"
1004 );
1005 }
1006}
1007
1008#[cfg(test)]
1009mod file_summary_proptests {
1010 use super::*;
1011 use crate::test_strategies::arb_verdict;
1012 use proptest::prelude::*;
1013
1014 proptest! {
1015 #![proptest_config(ProptestConfig::with_cases(256))]
1016
1017 #[test]
1019 fn prop_partition_completeness(
1020 verdicts in prop::collection::vec(arb_verdict(), 0..50)
1021 ) {
1022 let summaries = compute_file_summaries(&verdicts);
1023 let total: usize = summaries.iter().map(|f| f.function_count).sum();
1024 prop_assert_eq!(total, verdicts.len());
1025 }
1026
1027 #[test]
1029 fn prop_exceeding_count_aggregation(
1030 verdicts in prop::collection::vec(arb_verdict(), 0..50)
1031 ) {
1032 let summaries = compute_file_summaries(&verdicts);
1033 let sum: usize = summaries.iter().map(|f| f.exceeding_count).sum();
1034 let manual = verdicts.iter().filter(|v| v.exceeds).count();
1035 prop_assert_eq!(sum, manual);
1036 }
1037
1038 #[test]
1040 fn prop_one_row_per_distinct_file(
1041 verdicts in prop::collection::vec(arb_verdict(), 0..50)
1042 ) {
1043 let summaries = compute_file_summaries(&verdicts);
1044 let distinct: std::collections::HashSet<&str> = verdicts
1045 .iter()
1046 .map(|v| v.scored.identity.file_path.as_str())
1047 .collect();
1048 prop_assert_eq!(summaries.len(), distinct.len());
1049 let summary_paths: std::collections::HashSet<&str> =
1051 summaries.iter().map(|f| f.file_path.as_str()).collect();
1052 prop_assert_eq!(summary_paths, distinct);
1053 }
1054
1055 #[test]
1057 fn prop_max_crap_correct(
1058 verdicts in prop::collection::vec(arb_verdict(), 1..50)
1059 ) {
1060 let summaries = compute_file_summaries(&verdicts);
1061 for f in &summaries {
1062 let in_file: Vec<f64> = verdicts
1063 .iter()
1064 .filter(|v| v.scored.identity.file_path == f.file_path)
1065 .map(|v| v.scored.crap.value)
1066 .collect();
1067 let manual_max = in_file
1068 .iter()
1069 .copied()
1070 .fold(f64::NEG_INFINITY, f64::max);
1071 let got = f.max_crap.unwrap().value;
1072 prop_assert!((got - manual_max).abs() < 1e-9,
1073 "file {} max_crap mismatch: got {got}, expected {manual_max}",
1074 f.file_path);
1075 }
1076 }
1077
1078 #[test]
1080 fn prop_average_crap_correct(
1081 verdicts in prop::collection::vec(arb_verdict(), 1..50)
1082 ) {
1083 let summaries = compute_file_summaries(&verdicts);
1084 for f in &summaries {
1085 let in_file: Vec<f64> = verdicts
1086 .iter()
1087 .filter(|v| v.scored.identity.file_path == f.file_path)
1088 .map(|v| v.scored.crap.value)
1089 .collect();
1090 let manual_mean = in_file.iter().sum::<f64>() / in_file.len() as f64;
1091 prop_assert!((f.average_crap - manual_mean).abs() < 1e-6,
1092 "file {} average_crap mismatch: got {}, expected {}",
1093 f.file_path, f.average_crap, manual_mean);
1094 }
1095 }
1096
1097 #[test]
1099 fn prop_never_panics(
1100 verdicts in prop::collection::vec(arb_verdict(), 0..50)
1101 ) {
1102 let _ = compute_file_summaries(&verdicts);
1103 }
1104 }
1105}
1106
1107#[cfg(test)]
1108mod crap_delta_tests {
1109 use super::*;
1110 use crate::domain::delta::{self, AnalysisDelta};
1111 use crate::domain::types::{
1112 AnalysisResult, ComplexityMetric, CrapScore, FunctionIdentity, ScoredFunction, SourceSpan,
1113 };
1114
1115 fn make_verdict_at(
1116 file: &str,
1117 name: &str,
1118 line: usize,
1119 crap_value: f64,
1120 threshold: f64,
1121 ) -> FunctionVerdict {
1122 let risk_level = super::super::crap::classify_risk(crap_value);
1123 FunctionVerdict {
1124 scored: ScoredFunction {
1125 identity: FunctionIdentity {
1126 file_path: file.to_string(),
1127 qualified_name: name.to_string(),
1128 span: SourceSpan {
1129 start_line: line,
1130 end_line: line + 5,
1131 start_column: 0,
1132 end_column: 0,
1133 },
1134 },
1135 complexity: 1,
1136 complexity_metric: ComplexityMetric::Cognitive,
1137 coverage_percent: 100.0,
1138 crap: CrapScore {
1139 value: crap_value,
1140 risk_level,
1141 },
1142 contributors: vec![],
1143 },
1144 threshold,
1145 exceeds: crap_value > threshold,
1146 diagnostic: None,
1147 }
1148 }
1149
1150 fn make_result(verdicts: Vec<FunctionVerdict>) -> AnalysisResult {
1151 let summary = compute_summary(&verdicts);
1152 let passed = summary.exceeding_threshold == 0;
1153 AnalysisResult {
1154 functions: verdicts,
1155 summary,
1156 passed,
1157 }
1158 }
1159
1160 fn make_delta(baseline: &AnalysisResult, current: &AnalysisResult) -> AnalysisDelta {
1161 delta::compute(baseline.clone(), current.clone())
1162 }
1163
1164 #[test]
1167 fn no_baseline_no_violations_is_green_with_zero_delta() {
1168 let current = make_result(vec![make_verdict_at("a.rs", "ok", 1, 5.0, 15.0)]);
1169 let row = project_crap_delta_row(¤t, None, None, 15);
1170 assert_eq!(row.status, CrapDeltaStatus::Green);
1171 assert_eq!(row.delta_count, 0);
1172 assert_eq!(row.threshold, 15);
1173 assert_eq!(row.delta_text, "0 over threshold (no baseline)");
1174 assert!(row.failure_detail_md.is_none());
1175 }
1176
1177 #[test]
1178 fn no_baseline_with_violations_is_red_with_absolute_count() {
1179 let current = make_result(vec![
1180 make_verdict_at("a.rs", "ok", 1, 5.0, 15.0),
1181 make_verdict_at("a.rs", "bad", 10, 23.4, 15.0),
1182 make_verdict_at("b.rs", "worse", 4, 30.0, 15.0),
1183 ]);
1184 let row = project_crap_delta_row(¤t, None, None, 15);
1185 assert_eq!(row.status, CrapDeltaStatus::Red);
1186 assert_eq!(row.delta_count, 2);
1187 assert_eq!(row.delta_text, "2 over threshold (no baseline)");
1188 let detail = row.failure_detail_md.expect("Red implies failure_detail");
1189 assert!(detail.starts_with("**Functions over CRAP threshold (>15):**"));
1190 let worse_idx = detail.find("worse").expect("worse listed");
1192 let bad_idx = detail.find("bad").expect("bad listed");
1193 assert!(worse_idx < bad_idx, "expected CRAP-desc order");
1194 assert!(detail.contains("`b.rs:4`"));
1195 assert!(detail.contains("CRAP 30.0"));
1196 }
1197
1198 #[test]
1201 fn red_when_added_function_exceeds_threshold() {
1202 let baseline = make_result(vec![make_verdict_at("a.rs", "stable", 1, 5.0, 15.0)]);
1203 let current = make_result(vec![
1204 make_verdict_at("a.rs", "stable", 1, 5.0, 15.0),
1205 make_verdict_at("a.rs", "newly_added", 20, 22.0, 15.0),
1206 ]);
1207 let analysis_delta = make_delta(&baseline, ¤t);
1208 let row = project_crap_delta_row(
1209 ¤t,
1210 Some(&baseline),
1211 Some((&analysis_delta.summary, &analysis_delta.changes)),
1212 15,
1213 );
1214 assert_eq!(row.status, CrapDeltaStatus::Red);
1215 assert_eq!(row.delta_count, 1);
1216 assert_eq!(row.delta_text, "0 → 1 (+1)");
1217 let detail = row.failure_detail_md.expect("Red implies failure_detail");
1218 assert!(detail.contains("newly_added"));
1219 assert!(detail.contains("newly added"));
1220 }
1221
1222 #[test]
1223 fn red_when_modified_function_crosses_threshold() {
1224 let baseline = make_result(vec![make_verdict_at("a.rs", "borderline", 1, 11.2, 15.0)]);
1225 let current = make_result(vec![make_verdict_at("a.rs", "borderline", 1, 23.4, 15.0)]);
1226 let analysis_delta = make_delta(&baseline, ¤t);
1227 let row = project_crap_delta_row(
1228 ¤t,
1229 Some(&baseline),
1230 Some((&analysis_delta.summary, &analysis_delta.changes)),
1231 15,
1232 );
1233 assert_eq!(row.status, CrapDeltaStatus::Red);
1234 assert_eq!(row.delta_count, 1);
1235 assert_eq!(row.delta_text, "0 → 1 (+1)");
1236 let detail = row.failure_detail_md.expect("Red implies failure_detail");
1237 assert!(detail.contains("borderline"));
1238 assert!(detail.contains("CRAP 23.4"));
1239 assert!(detail.contains("was 11.2"));
1240 }
1241
1242 #[test]
1245 fn yellow_when_existing_function_regresses_below_threshold() {
1246 let baseline = make_result(vec![make_verdict_at("a.rs", "fn", 1, 5.0, 15.0)]);
1247 let current = make_result(vec![make_verdict_at("a.rs", "fn", 1, 9.0, 15.0)]);
1248 let analysis_delta = make_delta(&baseline, ¤t);
1249 let row = project_crap_delta_row(
1250 ¤t,
1251 Some(&baseline),
1252 Some((&analysis_delta.summary, &analysis_delta.changes)),
1253 15,
1254 );
1255 assert_eq!(row.status, CrapDeltaStatus::Yellow);
1256 assert_eq!(row.delta_count, 0);
1257 assert_eq!(row.delta_text, "0 → 0 (regressions on existing functions)");
1258 assert!(row.failure_detail_md.is_none());
1259 }
1260
1261 #[test]
1264 fn green_when_no_changes_at_all() {
1265 let baseline = make_result(vec![make_verdict_at("a.rs", "fn", 1, 5.0, 15.0)]);
1266 let current = make_result(vec![make_verdict_at("a.rs", "fn", 1, 5.0, 15.0)]);
1267 let analysis_delta = make_delta(&baseline, ¤t);
1268 let row = project_crap_delta_row(
1269 ¤t,
1270 Some(&baseline),
1271 Some((&analysis_delta.summary, &analysis_delta.changes)),
1272 15,
1273 );
1274 assert_eq!(row.status, CrapDeltaStatus::Green);
1275 assert_eq!(row.delta_count, 0);
1276 assert_eq!(row.delta_text, "0 → 0");
1277 assert!(row.failure_detail_md.is_none());
1278 }
1279
1280 #[test]
1281 fn green_when_violation_dropped_via_improvement() {
1282 let baseline = make_result(vec![make_verdict_at("a.rs", "was_bad", 1, 22.0, 15.0)]);
1283 let current = make_result(vec![make_verdict_at("a.rs", "was_bad", 1, 8.0, 15.0)]);
1284 let analysis_delta = make_delta(&baseline, ¤t);
1285 let row = project_crap_delta_row(
1286 ¤t,
1287 Some(&baseline),
1288 Some((&analysis_delta.summary, &analysis_delta.changes)),
1289 15,
1290 );
1291 assert_eq!(row.status, CrapDeltaStatus::Green);
1292 assert_eq!(row.delta_count, -1);
1293 assert_eq!(row.delta_text, "1 → 0 (-1)");
1294 assert!(row.failure_detail_md.is_none());
1295 }
1296
1297 #[test]
1298 fn green_when_violation_dropped_via_removal() {
1299 let baseline = make_result(vec![
1300 make_verdict_at("a.rs", "stable", 1, 5.0, 15.0),
1301 make_verdict_at("a.rs", "deleted_bad", 20, 22.0, 15.0),
1302 ]);
1303 let current = make_result(vec![make_verdict_at("a.rs", "stable", 1, 5.0, 15.0)]);
1304 let analysis_delta = make_delta(&baseline, ¤t);
1305 let row = project_crap_delta_row(
1306 ¤t,
1307 Some(&baseline),
1308 Some((&analysis_delta.summary, &analysis_delta.changes)),
1309 15,
1310 );
1311 assert_eq!(row.status, CrapDeltaStatus::Green);
1312 assert_eq!(row.delta_count, -1);
1313 assert_eq!(row.delta_text, "1 → 0 (-1)");
1314 assert!(row.failure_detail_md.is_none());
1315 }
1316
1317 #[test]
1320 fn red_detail_lists_violators_sorted_by_crap_descending() {
1321 let baseline = make_result(vec![]);
1322 let current = make_result(vec![
1323 make_verdict_at("a.rs", "low_violator", 1, 16.0, 15.0),
1324 make_verdict_at("b.rs", "high_violator", 1, 30.0, 15.0),
1325 make_verdict_at("c.rs", "mid_violator", 1, 22.0, 15.0),
1326 ]);
1327 let analysis_delta = make_delta(&baseline, ¤t);
1328 let row = project_crap_delta_row(
1329 ¤t,
1330 Some(&baseline),
1331 Some((&analysis_delta.summary, &analysis_delta.changes)),
1332 15,
1333 );
1334 let detail = row.failure_detail_md.expect("Red implies failure_detail");
1335 let high_idx = detail.find("high_violator").unwrap();
1336 let mid_idx = detail.find("mid_violator").unwrap();
1337 let low_idx = detail.find("low_violator").unwrap();
1338 assert!(high_idx < mid_idx);
1339 assert!(mid_idx < low_idx);
1340 }
1341
1342 #[test]
1345 fn threshold_value_passed_through() {
1346 let current = make_result(vec![]);
1347 let row = project_crap_delta_row(¤t, None, None, 25);
1348 assert_eq!(row.threshold, 25);
1349 }
1350}