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 branch_coverage_percent: None,
586 crap: CrapScore {
587 value: crap_value,
588 risk_level,
589 },
590 contributors: vec![],
591 },
592 threshold,
593 exceeds: crap_value > threshold,
594 diagnostic: None,
595 }
596 }
597
598 #[test]
599 fn empty_verdicts() {
600 let summary = compute_summary(&[]);
601 assert_eq!(summary.total_functions, 0);
602 assert_eq!(summary.total_files, 0);
603 assert_eq!(summary.exceeding_threshold, 0);
604 assert_eq!(summary.average_crap, 0.0);
605 assert_eq!(summary.median_crap, 0.0);
606 assert!(summary.max_crap.is_none());
607 assert!(summary.worst_function.is_none());
608 assert_eq!(summary.distribution.low, 0);
609 assert_eq!(summary.distribution.acceptable, 0);
610 assert_eq!(summary.distribution.moderate, 0);
611 assert_eq!(summary.distribution.high, 0);
612 }
613
614 #[test]
615 fn single_verdict() {
616 let v = make_verdict("a.rs", "foo", 3.0, 30.0);
617 let summary = compute_summary(&[v]);
618 assert_eq!(summary.total_functions, 1);
619 assert_eq!(summary.total_files, 1);
620 assert_eq!(summary.exceeding_threshold, 0);
621 assert_eq!(summary.average_crap, 3.0);
622 assert_eq!(summary.median_crap, 3.0);
623 assert_eq!(summary.max_crap.unwrap().value, 3.0);
624 assert_eq!(
625 summary.worst_function.as_ref().unwrap().qualified_name,
626 "foo"
627 );
628 }
629
630 #[test]
631 fn odd_count_median() {
632 let verdicts = vec![
633 make_verdict("a.rs", "a", 1.0, 30.0),
634 make_verdict("a.rs", "b", 5.0, 30.0),
635 make_verdict("a.rs", "c", 9.0, 30.0),
636 ];
637 let summary = compute_summary(&verdicts);
638 assert_eq!(summary.median_crap, 5.0);
639 }
640
641 #[test]
642 fn even_count_median() {
643 let verdicts = vec![
644 make_verdict("a.rs", "a", 2.0, 30.0),
645 make_verdict("a.rs", "b", 4.0, 30.0),
646 make_verdict("a.rs", "c", 6.0, 30.0),
647 make_verdict("a.rs", "d", 8.0, 30.0),
648 ];
649 let summary = compute_summary(&verdicts);
650 assert_eq!(summary.median_crap, 5.0);
652 }
653
654 #[test]
655 fn distribution_counting() {
656 let verdicts = vec![
657 make_verdict("a.rs", "low", 2.0, 30.0), make_verdict("a.rs", "acceptable", 10.0, 30.0), make_verdict("a.rs", "moderate", 20.0, 30.0), make_verdict("a.rs", "high", 50.0, 30.0), ];
662 let summary = compute_summary(&verdicts);
663 assert_eq!(summary.distribution.low, 1);
664 assert_eq!(summary.distribution.acceptable, 1);
665 assert_eq!(summary.distribution.moderate, 1);
666 assert_eq!(summary.distribution.high, 1);
667 }
668
669 #[test]
670 fn max_crap_and_worst_function() {
671 let verdicts = vec![
672 make_verdict("a.rs", "small", 2.0, 30.0),
673 make_verdict("b.rs", "big", 50.0, 30.0),
674 make_verdict("c.rs", "medium", 10.0, 30.0),
675 ];
676 let summary = compute_summary(&verdicts);
677 assert_eq!(summary.max_crap.unwrap().value, 50.0);
678 assert_eq!(
679 summary.worst_function.as_ref().unwrap().qualified_name,
680 "big"
681 );
682 }
683
684 #[test]
685 fn file_deduplication() {
686 let verdicts = vec![
687 make_verdict("a.rs", "foo", 2.0, 30.0),
688 make_verdict("a.rs", "bar", 3.0, 30.0),
689 make_verdict("b.rs", "baz", 4.0, 30.0),
690 ];
691 let summary = compute_summary(&verdicts);
692 assert_eq!(summary.total_functions, 3);
693 assert_eq!(summary.total_files, 2);
694 }
695
696 #[test]
697 fn exceeding_threshold_count() {
698 let verdicts = vec![
699 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), ];
703 let summary = compute_summary(&verdicts);
704 assert_eq!(summary.exceeding_threshold, 2);
705 }
706
707 #[test]
708 fn average_calculation() {
709 let verdicts = vec![
710 make_verdict("a.rs", "a", 3.0, 30.0),
711 make_verdict("a.rs", "b", 6.0, 30.0),
712 make_verdict("a.rs", "c", 9.0, 30.0),
713 ];
714 let summary = compute_summary(&verdicts);
715 assert_eq!(summary.average_crap, 6.0);
716 }
717
718 #[test]
719 fn tied_scores_first_wins_worst_function() {
720 let verdicts = vec![
721 make_verdict("a.rs", "first", 10.0, 30.0),
722 make_verdict("a.rs", "second", 10.0, 30.0),
723 ];
724 let summary = compute_summary(&verdicts);
725 assert_eq!(
726 summary.worst_function.as_ref().unwrap().qualified_name,
727 "first"
728 );
729 }
730}
731
732#[cfg(test)]
733mod file_summary_tests {
734 use super::*;
735 use crate::domain::types::{
736 ComplexityMetric, CrapScore, FunctionIdentity, ScoredFunction, SourceSpan,
737 };
738
739 fn vrd(file: &str, name: &str, crap_value: f64, threshold: f64) -> FunctionVerdict {
740 let risk_level = super::super::crap::classify_risk(crap_value);
741 FunctionVerdict {
742 scored: ScoredFunction {
743 identity: FunctionIdentity {
744 file_path: file.to_string(),
745 qualified_name: name.to_string(),
746 span: SourceSpan {
747 start_line: 1,
748 end_line: 10,
749 start_column: 0,
750 end_column: 0,
751 },
752 },
753 complexity: 1,
754 complexity_metric: ComplexityMetric::Cognitive,
755 coverage_percent: 100.0,
756 branch_coverage_percent: None,
757 crap: CrapScore {
758 value: crap_value,
759 risk_level,
760 },
761 contributors: vec![],
762 },
763 threshold,
764 exceeds: crap_value > threshold,
765 diagnostic: None,
766 }
767 }
768
769 #[test]
770 fn empty_input_returns_empty_vec() {
771 let result = compute_file_summaries(&[]);
773 assert!(result.is_empty());
774 }
775
776 #[test]
777 fn single_file_single_function() {
778 let v = vrd("a.rs", "foo", 3.0, 25.0);
779 let summaries = compute_file_summaries(&[v]);
780 assert_eq!(summaries.len(), 1);
781 let f = &summaries[0];
782 assert_eq!(f.file_path, "a.rs");
783 assert_eq!(f.function_count, 1);
784 assert_eq!(f.exceeding_count, 0);
785 assert_eq!(f.average_crap, 3.0);
786 assert_eq!(f.median_crap, 3.0);
787 assert_eq!(f.max_crap.unwrap().value, 3.0);
788 assert_eq!(f.worst_function.as_ref().unwrap().qualified_name, "foo");
789 }
790
791 #[test]
792 fn partition_completeness() {
793 let verdicts = vec![
795 vrd("a.rs", "a1", 1.0, 25.0),
796 vrd("a.rs", "a2", 2.0, 25.0),
797 vrd("b.rs", "b1", 30.0, 25.0),
798 vrd("c.rs", "c1", 4.0, 25.0),
799 ];
800 let summaries = compute_file_summaries(&verdicts);
801 let total: usize = summaries.iter().map(|f| f.function_count).sum();
802 assert_eq!(total, verdicts.len());
803 }
804
805 #[test]
806 fn distinct_files_are_grouped() {
807 let verdicts = vec![
809 vrd("a.rs", "a1", 1.0, 25.0),
810 vrd("a.rs", "a2", 2.0, 25.0),
811 vrd("b.rs", "b1", 30.0, 25.0),
812 vrd("c.rs", "c1", 4.0, 25.0),
813 ];
814 let summaries = compute_file_summaries(&verdicts);
815 assert_eq!(summaries.len(), 3);
816 let mut paths: Vec<&str> = summaries.iter().map(|f| f.file_path.as_str()).collect();
817 paths.sort();
818 assert_eq!(paths, vec!["a.rs", "b.rs", "c.rs"]);
819 }
820
821 #[test]
822 fn exceeding_count_aggregates_per_file() {
823 let verdicts = vec![
825 vrd("a.rs", "ok", 5.0, 10.0),
826 vrd("a.rs", "bad1", 15.0, 10.0),
827 vrd("a.rs", "bad2", 50.0, 10.0),
828 vrd("b.rs", "ok2", 3.0, 10.0),
829 ];
830 let summaries = compute_file_summaries(&verdicts);
831 let a = summaries.iter().find(|f| f.file_path == "a.rs").unwrap();
832 let b = summaries.iter().find(|f| f.file_path == "b.rs").unwrap();
833 assert_eq!(a.exceeding_count, 2);
834 assert_eq!(b.exceeding_count, 0);
835 }
836
837 #[test]
838 fn exceeding_count_sum_matches_total() {
839 let verdicts = vec![
841 vrd("a.rs", "ok", 5.0, 10.0),
842 vrd("a.rs", "bad1", 15.0, 10.0),
843 vrd("b.rs", "bad2", 50.0, 10.0),
844 vrd("c.rs", "ok2", 3.0, 10.0),
845 ];
846 let summaries = compute_file_summaries(&verdicts);
847 let sum: usize = summaries.iter().map(|f| f.exceeding_count).sum();
848 let manual = verdicts.iter().filter(|v| v.exceeds).count();
849 assert_eq!(sum, manual);
850 }
851
852 #[test]
853 fn max_crap_per_file_correct() {
854 let verdicts = vec![
856 vrd("a.rs", "low", 5.0, 25.0),
857 vrd("a.rs", "high", 50.0, 25.0),
858 vrd("b.rs", "med", 12.0, 25.0),
859 ];
860 let summaries = compute_file_summaries(&verdicts);
861 let a = summaries.iter().find(|f| f.file_path == "a.rs").unwrap();
862 let b = summaries.iter().find(|f| f.file_path == "b.rs").unwrap();
863 assert_eq!(a.max_crap.unwrap().value, 50.0);
864 assert_eq!(a.worst_function.as_ref().unwrap().qualified_name, "high");
865 assert_eq!(b.max_crap.unwrap().value, 12.0);
866 }
867
868 #[test]
869 fn average_crap_per_file_correct() {
870 let verdicts = vec![
872 vrd("a.rs", "a1", 4.0, 25.0),
873 vrd("a.rs", "a2", 6.0, 25.0),
874 vrd("a.rs", "a3", 14.0, 25.0),
875 vrd("b.rs", "b1", 3.0, 25.0),
876 ];
877 let summaries = compute_file_summaries(&verdicts);
878 let a = summaries.iter().find(|f| f.file_path == "a.rs").unwrap();
879 let b = summaries.iter().find(|f| f.file_path == "b.rs").unwrap();
880 assert!((a.average_crap - 8.0).abs() < 1e-9);
882 assert!((b.average_crap - 3.0).abs() < 1e-9);
883 }
884
885 #[test]
886 fn median_per_file_odd_and_even() {
887 let verdicts = vec![
888 vrd("a.rs", "a1", 1.0, 25.0),
889 vrd("a.rs", "a2", 5.0, 25.0),
890 vrd("a.rs", "a3", 9.0, 25.0),
891 vrd("b.rs", "b1", 2.0, 25.0),
892 vrd("b.rs", "b2", 8.0, 25.0),
893 ];
894 let summaries = compute_file_summaries(&verdicts);
895 let a = summaries.iter().find(|f| f.file_path == "a.rs").unwrap();
896 let b = summaries.iter().find(|f| f.file_path == "b.rs").unwrap();
897 assert_eq!(a.median_crap, 5.0);
898 assert_eq!(b.median_crap, 5.0); }
900
901 #[test]
902 fn distribution_per_file() {
903 let verdicts = vec![
904 vrd("a.rs", "low", 2.0, 25.0), vrd("a.rs", "acceptable", 10.0, 25.0), vrd("a.rs", "moderate", 20.0, 25.0), vrd("a.rs", "high", 50.0, 25.0), ];
909 let summaries = compute_file_summaries(&verdicts);
910 assert_eq!(summaries.len(), 1);
911 let d = &summaries[0].distribution;
912 assert_eq!(d.low, 1);
913 assert_eq!(d.acceptable, 1);
914 assert_eq!(d.moderate, 1);
915 assert_eq!(d.high, 1);
916 }
917
918 #[test]
919 fn nan_coverage_does_not_panic() {
920 let v = FunctionVerdict {
923 scored: ScoredFunction {
924 identity: FunctionIdentity {
925 file_path: "a.rs".to_string(),
926 qualified_name: "f".to_string(),
927 span: SourceSpan {
928 start_line: 1,
929 end_line: 10,
930 start_column: 0,
931 end_column: 0,
932 },
933 },
934 complexity: 1,
935 complexity_metric: ComplexityMetric::Cognitive,
936 coverage_percent: f64::NAN,
937 branch_coverage_percent: None,
938 crap: CrapScore {
939 value: 5.0,
940 risk_level: RiskLevel::Low,
941 },
942 contributors: vec![],
943 },
944 threshold: 25.0,
945 exceeds: false,
946 diagnostic: None,
947 };
948 let summaries = compute_file_summaries(&[v]);
949 assert_eq!(summaries.len(), 1);
950 assert_eq!(summaries[0].function_count, 1);
951 assert_eq!(summaries[0].average_coverage, 0.0);
954 assert!(!summaries[0].average_coverage.is_nan());
955 }
956
957 #[test]
958 fn file_summary_for_empty_input_does_not_divide_by_zero() {
959 let summary = super::file_summary_for("empty.rs".to_string(), &[]);
965 assert_eq!(summary.function_count, 0);
966 assert_eq!(summary.average_crap, 0.0);
967 assert_eq!(summary.average_coverage, 0.0);
968 assert!(!summary.average_crap.is_nan());
969 assert!(!summary.average_coverage.is_nan());
970 }
971
972 #[test]
973 fn average_coverage_excludes_nan() {
974 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);
977 v_finite_b.scored.coverage_percent = 50.0;
978 let mut v_nan = vrd("a.rs", "f3", 1.0, 25.0);
979 v_nan.scored.coverage_percent = f64::NAN;
980 let summaries = compute_file_summaries(&[v_finite_a, v_finite_b, v_nan]);
981 assert!((summaries[0].average_coverage - 75.0).abs() < 1e-9);
982 }
983
984 #[test]
985 fn max_complexity_per_file() {
986 let mut v1 = vrd("a.rs", "small", 1.0, 25.0);
987 v1.scored.complexity = 3;
988 let mut v2 = vrd("a.rs", "big", 1.0, 25.0);
989 v2.scored.complexity = 17;
990 let mut v3 = vrd("a.rs", "med", 1.0, 25.0);
991 v3.scored.complexity = 8;
992 let summaries = compute_file_summaries(&[v1, v2, v3]);
993 assert_eq!(summaries[0].max_complexity, 17);
994 }
995
996 #[test]
997 fn tied_max_first_wins() {
998 let verdicts = vec![
1000 vrd("a.rs", "first", 10.0, 25.0),
1001 vrd("a.rs", "second", 10.0, 25.0),
1002 ];
1003 let summaries = compute_file_summaries(&verdicts);
1004 assert_eq!(
1005 summaries[0].worst_function.as_ref().unwrap().qualified_name,
1006 "first"
1007 );
1008 }
1009}
1010
1011#[cfg(test)]
1012mod file_summary_proptests {
1013 use super::*;
1014 use crate::test_strategies::arb_verdict;
1015 use proptest::prelude::*;
1016
1017 proptest! {
1018 #![proptest_config(ProptestConfig::with_cases(256))]
1019
1020 #[test]
1022 fn prop_partition_completeness(
1023 verdicts in prop::collection::vec(arb_verdict(), 0..50)
1024 ) {
1025 let summaries = compute_file_summaries(&verdicts);
1026 let total: usize = summaries.iter().map(|f| f.function_count).sum();
1027 prop_assert_eq!(total, verdicts.len());
1028 }
1029
1030 #[test]
1032 fn prop_exceeding_count_aggregation(
1033 verdicts in prop::collection::vec(arb_verdict(), 0..50)
1034 ) {
1035 let summaries = compute_file_summaries(&verdicts);
1036 let sum: usize = summaries.iter().map(|f| f.exceeding_count).sum();
1037 let manual = verdicts.iter().filter(|v| v.exceeds).count();
1038 prop_assert_eq!(sum, manual);
1039 }
1040
1041 #[test]
1043 fn prop_one_row_per_distinct_file(
1044 verdicts in prop::collection::vec(arb_verdict(), 0..50)
1045 ) {
1046 let summaries = compute_file_summaries(&verdicts);
1047 let distinct: std::collections::HashSet<&str> = verdicts
1048 .iter()
1049 .map(|v| v.scored.identity.file_path.as_str())
1050 .collect();
1051 prop_assert_eq!(summaries.len(), distinct.len());
1052 let summary_paths: std::collections::HashSet<&str> =
1054 summaries.iter().map(|f| f.file_path.as_str()).collect();
1055 prop_assert_eq!(summary_paths, distinct);
1056 }
1057
1058 #[test]
1060 fn prop_max_crap_correct(
1061 verdicts in prop::collection::vec(arb_verdict(), 1..50)
1062 ) {
1063 let summaries = compute_file_summaries(&verdicts);
1064 for f in &summaries {
1065 let in_file: Vec<f64> = verdicts
1066 .iter()
1067 .filter(|v| v.scored.identity.file_path == f.file_path)
1068 .map(|v| v.scored.crap.value)
1069 .collect();
1070 let manual_max = in_file
1071 .iter()
1072 .copied()
1073 .fold(f64::NEG_INFINITY, f64::max);
1074 let got = f.max_crap.unwrap().value;
1075 prop_assert!((got - manual_max).abs() < 1e-9,
1076 "file {} max_crap mismatch: got {got}, expected {manual_max}",
1077 f.file_path);
1078 }
1079 }
1080
1081 #[test]
1083 fn prop_average_crap_correct(
1084 verdicts in prop::collection::vec(arb_verdict(), 1..50)
1085 ) {
1086 let summaries = compute_file_summaries(&verdicts);
1087 for f in &summaries {
1088 let in_file: Vec<f64> = verdicts
1089 .iter()
1090 .filter(|v| v.scored.identity.file_path == f.file_path)
1091 .map(|v| v.scored.crap.value)
1092 .collect();
1093 let manual_mean = in_file.iter().sum::<f64>() / in_file.len() as f64;
1094 prop_assert!((f.average_crap - manual_mean).abs() < 1e-6,
1095 "file {} average_crap mismatch: got {}, expected {}",
1096 f.file_path, f.average_crap, manual_mean);
1097 }
1098 }
1099
1100 #[test]
1102 fn prop_never_panics(
1103 verdicts in prop::collection::vec(arb_verdict(), 0..50)
1104 ) {
1105 let _ = compute_file_summaries(&verdicts);
1106 }
1107 }
1108}
1109
1110#[cfg(test)]
1111mod crap_delta_tests {
1112 use super::*;
1113 use crate::domain::delta::{self, AnalysisDelta};
1114 use crate::domain::types::{
1115 AnalysisResult, ComplexityMetric, CrapScore, FunctionIdentity, ScoredFunction, SourceSpan,
1116 };
1117
1118 fn make_verdict_at(
1119 file: &str,
1120 name: &str,
1121 line: usize,
1122 crap_value: f64,
1123 threshold: f64,
1124 ) -> FunctionVerdict {
1125 let risk_level = super::super::crap::classify_risk(crap_value);
1126 FunctionVerdict {
1127 scored: ScoredFunction {
1128 identity: FunctionIdentity {
1129 file_path: file.to_string(),
1130 qualified_name: name.to_string(),
1131 span: SourceSpan {
1132 start_line: line,
1133 end_line: line + 5,
1134 start_column: 0,
1135 end_column: 0,
1136 },
1137 },
1138 complexity: 1,
1139 complexity_metric: ComplexityMetric::Cognitive,
1140 coverage_percent: 100.0,
1141 branch_coverage_percent: None,
1142 crap: CrapScore {
1143 value: crap_value,
1144 risk_level,
1145 },
1146 contributors: vec![],
1147 },
1148 threshold,
1149 exceeds: crap_value > threshold,
1150 diagnostic: None,
1151 }
1152 }
1153
1154 fn make_result(verdicts: Vec<FunctionVerdict>) -> AnalysisResult {
1155 let summary = compute_summary(&verdicts);
1156 let passed = summary.exceeding_threshold == 0;
1157 AnalysisResult {
1158 functions: verdicts,
1159 summary,
1160 passed,
1161 }
1162 }
1163
1164 fn make_delta(baseline: &AnalysisResult, current: &AnalysisResult) -> AnalysisDelta {
1165 delta::compute(baseline.clone(), current.clone())
1166 }
1167
1168 #[test]
1171 fn no_baseline_no_violations_is_green_with_zero_delta() {
1172 let current = make_result(vec![make_verdict_at("a.rs", "ok", 1, 5.0, 15.0)]);
1173 let row = project_crap_delta_row(¤t, None, None, 15);
1174 assert_eq!(row.status, CrapDeltaStatus::Green);
1175 assert_eq!(row.delta_count, 0);
1176 assert_eq!(row.threshold, 15);
1177 assert_eq!(row.delta_text, "0 over threshold (no baseline)");
1178 assert!(row.failure_detail_md.is_none());
1179 }
1180
1181 #[test]
1182 fn no_baseline_with_violations_is_red_with_absolute_count() {
1183 let current = make_result(vec![
1184 make_verdict_at("a.rs", "ok", 1, 5.0, 15.0),
1185 make_verdict_at("a.rs", "bad", 10, 23.4, 15.0),
1186 make_verdict_at("b.rs", "worse", 4, 30.0, 15.0),
1187 ]);
1188 let row = project_crap_delta_row(¤t, None, None, 15);
1189 assert_eq!(row.status, CrapDeltaStatus::Red);
1190 assert_eq!(row.delta_count, 2);
1191 assert_eq!(row.delta_text, "2 over threshold (no baseline)");
1192 let detail = row.failure_detail_md.expect("Red implies failure_detail");
1193 assert!(detail.starts_with("**Functions over CRAP threshold (>15):**"));
1194 let worse_idx = detail.find("worse").expect("worse listed");
1196 let bad_idx = detail.find("bad").expect("bad listed");
1197 assert!(worse_idx < bad_idx, "expected CRAP-desc order");
1198 assert!(detail.contains("`b.rs:4`"));
1199 assert!(detail.contains("CRAP 30.0"));
1200 }
1201
1202 #[test]
1205 fn red_when_added_function_exceeds_threshold() {
1206 let baseline = make_result(vec![make_verdict_at("a.rs", "stable", 1, 5.0, 15.0)]);
1207 let current = make_result(vec![
1208 make_verdict_at("a.rs", "stable", 1, 5.0, 15.0),
1209 make_verdict_at("a.rs", "newly_added", 20, 22.0, 15.0),
1210 ]);
1211 let analysis_delta = make_delta(&baseline, ¤t);
1212 let row = project_crap_delta_row(
1213 ¤t,
1214 Some(&baseline),
1215 Some((&analysis_delta.summary, &analysis_delta.changes)),
1216 15,
1217 );
1218 assert_eq!(row.status, CrapDeltaStatus::Red);
1219 assert_eq!(row.delta_count, 1);
1220 assert_eq!(row.delta_text, "0 → 1 (+1)");
1221 let detail = row.failure_detail_md.expect("Red implies failure_detail");
1222 assert!(detail.contains("newly_added"));
1223 assert!(detail.contains("newly added"));
1224 }
1225
1226 #[test]
1227 fn red_when_modified_function_crosses_threshold() {
1228 let baseline = make_result(vec![make_verdict_at("a.rs", "borderline", 1, 11.2, 15.0)]);
1229 let current = make_result(vec![make_verdict_at("a.rs", "borderline", 1, 23.4, 15.0)]);
1230 let analysis_delta = make_delta(&baseline, ¤t);
1231 let row = project_crap_delta_row(
1232 ¤t,
1233 Some(&baseline),
1234 Some((&analysis_delta.summary, &analysis_delta.changes)),
1235 15,
1236 );
1237 assert_eq!(row.status, CrapDeltaStatus::Red);
1238 assert_eq!(row.delta_count, 1);
1239 assert_eq!(row.delta_text, "0 → 1 (+1)");
1240 let detail = row.failure_detail_md.expect("Red implies failure_detail");
1241 assert!(detail.contains("borderline"));
1242 assert!(detail.contains("CRAP 23.4"));
1243 assert!(detail.contains("was 11.2"));
1244 }
1245
1246 #[test]
1249 fn yellow_when_existing_function_regresses_below_threshold() {
1250 let baseline = make_result(vec![make_verdict_at("a.rs", "fn", 1, 5.0, 15.0)]);
1251 let current = make_result(vec![make_verdict_at("a.rs", "fn", 1, 9.0, 15.0)]);
1252 let analysis_delta = make_delta(&baseline, ¤t);
1253 let row = project_crap_delta_row(
1254 ¤t,
1255 Some(&baseline),
1256 Some((&analysis_delta.summary, &analysis_delta.changes)),
1257 15,
1258 );
1259 assert_eq!(row.status, CrapDeltaStatus::Yellow);
1260 assert_eq!(row.delta_count, 0);
1261 assert_eq!(row.delta_text, "0 → 0 (regressions on existing functions)");
1262 assert!(row.failure_detail_md.is_none());
1263 }
1264
1265 #[test]
1268 fn green_when_no_changes_at_all() {
1269 let baseline = make_result(vec![make_verdict_at("a.rs", "fn", 1, 5.0, 15.0)]);
1270 let current = make_result(vec![make_verdict_at("a.rs", "fn", 1, 5.0, 15.0)]);
1271 let analysis_delta = make_delta(&baseline, ¤t);
1272 let row = project_crap_delta_row(
1273 ¤t,
1274 Some(&baseline),
1275 Some((&analysis_delta.summary, &analysis_delta.changes)),
1276 15,
1277 );
1278 assert_eq!(row.status, CrapDeltaStatus::Green);
1279 assert_eq!(row.delta_count, 0);
1280 assert_eq!(row.delta_text, "0 → 0");
1281 assert!(row.failure_detail_md.is_none());
1282 }
1283
1284 #[test]
1285 fn green_when_violation_dropped_via_improvement() {
1286 let baseline = make_result(vec![make_verdict_at("a.rs", "was_bad", 1, 22.0, 15.0)]);
1287 let current = make_result(vec![make_verdict_at("a.rs", "was_bad", 1, 8.0, 15.0)]);
1288 let analysis_delta = make_delta(&baseline, ¤t);
1289 let row = project_crap_delta_row(
1290 ¤t,
1291 Some(&baseline),
1292 Some((&analysis_delta.summary, &analysis_delta.changes)),
1293 15,
1294 );
1295 assert_eq!(row.status, CrapDeltaStatus::Green);
1296 assert_eq!(row.delta_count, -1);
1297 assert_eq!(row.delta_text, "1 → 0 (-1)");
1298 assert!(row.failure_detail_md.is_none());
1299 }
1300
1301 #[test]
1302 fn green_when_violation_dropped_via_removal() {
1303 let baseline = make_result(vec![
1304 make_verdict_at("a.rs", "stable", 1, 5.0, 15.0),
1305 make_verdict_at("a.rs", "deleted_bad", 20, 22.0, 15.0),
1306 ]);
1307 let current = make_result(vec![make_verdict_at("a.rs", "stable", 1, 5.0, 15.0)]);
1308 let analysis_delta = make_delta(&baseline, ¤t);
1309 let row = project_crap_delta_row(
1310 ¤t,
1311 Some(&baseline),
1312 Some((&analysis_delta.summary, &analysis_delta.changes)),
1313 15,
1314 );
1315 assert_eq!(row.status, CrapDeltaStatus::Green);
1316 assert_eq!(row.delta_count, -1);
1317 assert_eq!(row.delta_text, "1 → 0 (-1)");
1318 assert!(row.failure_detail_md.is_none());
1319 }
1320
1321 #[test]
1324 fn red_detail_lists_violators_sorted_by_crap_descending() {
1325 let baseline = make_result(vec![]);
1326 let current = make_result(vec![
1327 make_verdict_at("a.rs", "low_violator", 1, 16.0, 15.0),
1328 make_verdict_at("b.rs", "high_violator", 1, 30.0, 15.0),
1329 make_verdict_at("c.rs", "mid_violator", 1, 22.0, 15.0),
1330 ]);
1331 let analysis_delta = make_delta(&baseline, ¤t);
1332 let row = project_crap_delta_row(
1333 ¤t,
1334 Some(&baseline),
1335 Some((&analysis_delta.summary, &analysis_delta.changes)),
1336 15,
1337 );
1338 let detail = row.failure_detail_md.expect("Red implies failure_detail");
1339 let high_idx = detail.find("high_violator").unwrap();
1340 let mid_idx = detail.find("mid_violator").unwrap();
1341 let low_idx = detail.find("low_violator").unwrap();
1342 assert!(high_idx < mid_idx);
1343 assert!(mid_idx < low_idx);
1344 }
1345
1346 #[test]
1349 fn threshold_value_passed_through() {
1350 let current = make_result(vec![]);
1351 let row = project_crap_delta_row(¤t, None, None, 25);
1352 assert_eq!(row.threshold, 25);
1353 }
1354}