1use crate::domain::types::{AnalysisResult, FunctionIdentity, FunctionVerdict};
18use serde::Serialize;
19use std::cmp::Ordering;
20use std::collections::{BTreeSet, HashMap};
21
22#[non_exhaustive]
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ChangeKind {
36 Added,
37 Removed,
38 Modified,
39}
40
41impl ChangeKind {
42 pub const ALL: [ChangeKind; 3] = [ChangeKind::Added, ChangeKind::Removed, ChangeKind::Modified];
45
46 pub fn as_str(&self) -> &'static str {
47 self.as_wire_str()
48 }
49
50 pub fn as_wire_str(&self) -> &'static str {
55 match self {
56 ChangeKind::Added => "added",
57 ChangeKind::Removed => "removed",
58 ChangeKind::Modified => "modified",
59 }
60 }
61}
62
63#[non_exhaustive]
78#[derive(Debug, Clone, Serialize)]
79#[serde(tag = "kind", rename_all = "lowercase")]
80pub enum FunctionChange {
81 Added {
82 current: FunctionVerdict,
83 },
84 Removed {
85 baseline: FunctionVerdict,
86 },
87 Modified {
88 baseline: FunctionVerdict,
89 current: FunctionVerdict,
90 },
91}
92
93impl FunctionChange {
94 pub fn kind(&self) -> ChangeKind {
95 match self {
96 FunctionChange::Added { .. } => ChangeKind::Added,
97 FunctionChange::Removed { .. } => ChangeKind::Removed,
98 FunctionChange::Modified { .. } => ChangeKind::Modified,
99 }
100 }
101
102 pub fn current_score(&self) -> Option<f64> {
103 match self {
104 FunctionChange::Added { current } => Some(current.scored.crap.value),
105 FunctionChange::Modified { current, .. } => Some(current.scored.crap.value),
106 FunctionChange::Removed { .. } => None,
107 }
108 }
109
110 pub fn baseline_score(&self) -> Option<f64> {
111 match self {
112 FunctionChange::Removed { baseline } => Some(baseline.scored.crap.value),
113 FunctionChange::Modified { baseline, .. } => Some(baseline.scored.crap.value),
114 FunctionChange::Added { .. } => None,
115 }
116 }
117
118 pub fn score_delta(&self) -> Option<f64> {
120 match self {
121 FunctionChange::Modified { baseline, current } => {
122 Some(current.scored.crap.value - baseline.scored.crap.value)
123 }
124 _ => None,
125 }
126 }
127
128 pub fn file_path(&self) -> &str {
131 match self {
132 FunctionChange::Added { current } => ¤t.scored.identity.file_path,
133 FunctionChange::Removed { baseline } => &baseline.scored.identity.file_path,
134 FunctionChange::Modified { current, .. } => ¤t.scored.identity.file_path,
135 }
136 }
137
138 pub fn qualified_name(&self) -> &str {
140 match self {
141 FunctionChange::Added { current } => ¤t.scored.identity.qualified_name,
142 FunctionChange::Removed { baseline } => &baseline.scored.identity.qualified_name,
143 FunctionChange::Modified { current, .. } => ¤t.scored.identity.qualified_name,
144 }
145 }
146}
147
148#[non_exhaustive]
157#[derive(Debug, Clone, Copy, Default, Serialize)]
158pub struct DeltaSummary {
159 pub added: u32,
160 pub removed: u32,
161 pub modified: u32,
162 pub regressions: u32,
164 pub improvements: u32,
166 pub new_violations: u32,
175 pub passed: bool,
177}
178
179impl DeltaSummary {
180 pub fn compute(changes: &[FunctionChange]) -> Self {
181 let mut summary = Self::default();
182 for change in changes {
183 tally(&mut summary, change);
184 }
185 summary.passed = summary.new_violations == 0;
186 summary
187 }
188}
189
190fn tally(summary: &mut DeltaSummary, change: &FunctionChange) {
191 match change {
192 FunctionChange::Added { current } => {
193 summary.added += 1;
194 if current.exceeds {
195 summary.new_violations += 1;
196 }
197 }
198 FunctionChange::Removed { .. } => {
199 summary.removed += 1;
200 }
201 FunctionChange::Modified { baseline, current } => {
202 summary.modified += 1;
203 tally_modified(summary, baseline, current);
204 }
205 }
206}
207
208fn tally_modified(
209 summary: &mut DeltaSummary,
210 baseline: &FunctionVerdict,
211 current: &FunctionVerdict,
212) {
213 let delta = current.scored.crap.value - baseline.scored.crap.value;
214 if delta > 0.0 {
215 summary.regressions += 1;
216 } else if delta < 0.0 {
217 summary.improvements += 1;
218 }
219 if !baseline.exceeds && current.exceeds {
220 summary.new_violations += 1;
221 }
222}
223
224#[non_exhaustive]
235#[derive(Debug, Clone, Serialize)]
236pub struct AnalysisDelta {
237 #[serde(skip)]
243 pub baseline: AnalysisResult,
244 #[serde(skip)]
245 pub current: AnalysisResult,
246 pub changes: Vec<FunctionChange>,
247 pub summary: DeltaSummary,
251}
252
253type IdentityKey<'a> = (&'a str, &'a str);
263
264fn identity_key(identity: &FunctionIdentity) -> IdentityKey<'_> {
265 (&identity.file_path, &identity.qualified_name)
266}
267
268pub fn compute(baseline: AnalysisResult, current: AnalysisResult) -> AnalysisDelta {
276 let changes = pair_identities(&baseline, ¤t);
277 let summary = DeltaSummary::compute(&changes);
278 AnalysisDelta {
279 baseline,
280 current,
281 changes,
282 summary,
283 }
284}
285
286fn pair_identities(baseline: &AnalysisResult, current: &AnalysisResult) -> Vec<FunctionChange> {
287 let mut baseline_index: HashMap<IdentityKey<'_>, &FunctionVerdict> =
291 HashMap::with_capacity(baseline.functions.len());
292 for verdict in &baseline.functions {
293 baseline_index.insert(identity_key(&verdict.scored.identity), verdict);
294 }
295
296 let mut changes: Vec<FunctionChange> =
297 Vec::with_capacity(current.functions.len() + baseline.functions.len());
298
299 for current_verdict in ¤t.functions {
300 let key = identity_key(¤t_verdict.scored.identity);
301 match baseline_index.remove(&key) {
302 Some(baseline_verdict) => changes.push(FunctionChange::Modified {
303 baseline: baseline_verdict.clone(),
304 current: current_verdict.clone(),
305 }),
306 None => changes.push(FunctionChange::Added {
307 current: current_verdict.clone(),
308 }),
309 }
310 }
311
312 let mut leftover: Vec<&FunctionVerdict> = baseline_index.into_values().collect();
320 leftover
321 .sort_by(|a, b| identity_key(&a.scored.identity).cmp(&identity_key(&b.scored.identity)));
322 for baseline_verdict in leftover {
323 changes.push(FunctionChange::Removed {
324 baseline: baseline_verdict.clone(),
325 });
326 }
327
328 changes
329}
330
331#[non_exhaustive]
341#[derive(Debug, Clone, Default, Serialize)]
342pub struct DeltaViewSpec {
343 pub filters: DeltaFilters,
344 pub sort: DeltaSortKey,
345 pub limit: Option<usize>,
346}
347
348#[non_exhaustive]
349#[derive(Debug, Clone, Default, Serialize)]
350pub struct DeltaFilters {
351 pub change_kinds: Option<BTreeSet<ChangeKind>>,
356 pub min_score_delta: Option<f64>,
361 pub max_score_delta: Option<f64>,
364}
365
366#[non_exhaustive]
376#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
377#[serde(rename_all = "snake_case")]
378pub enum DeltaSortKey {
379 #[default]
381 ScoreDelta,
382 CurrentCrap,
385 BaselineCrap,
387 Path,
389}
390
391impl DeltaSortKey {
392 pub fn as_wire_str(&self) -> &'static str {
394 match self {
395 Self::ScoreDelta => "score_delta",
396 Self::CurrentCrap => "current_crap",
397 Self::BaselineCrap => "baseline_crap",
398 Self::Path => "path",
399 }
400 }
401}
402
403#[non_exhaustive]
409#[derive(Debug, Serialize)]
410pub struct DeltaView<'a> {
411 #[serde(skip)]
415 pub full: &'a AnalysisDelta,
416 pub spec: DeltaViewSpec,
417 pub eligible_count: usize,
420 pub truncated: bool,
421 pub shown: Vec<&'a FunctionChange>,
422}
423
424pub fn apply<'a>(delta: &'a AnalysisDelta, spec: DeltaViewSpec) -> DeltaView<'a> {
430 let mut shown: Vec<&'a FunctionChange> = apply_filters(&delta.changes, &spec.filters);
431 let eligible_count = shown.len();
432 sort_in_place(&mut shown, spec.sort);
433 let truncated = truncate_to(&mut shown, spec.limit);
434 DeltaView {
435 full: delta,
436 spec,
437 eligible_count,
438 truncated,
439 shown,
440 }
441}
442
443fn apply_filters<'a>(
444 changes: &'a [FunctionChange],
445 filters: &DeltaFilters,
446) -> Vec<&'a FunctionChange> {
447 changes
448 .iter()
449 .filter(|c| {
450 filters
451 .change_kinds
452 .as_ref()
453 .is_none_or(|kinds| kinds.contains(&c.kind()))
454 })
455 .filter(|c| matches_score_delta_range(c, filters))
456 .collect()
457}
458
459fn matches_score_delta_range(change: &FunctionChange, filters: &DeltaFilters) -> bool {
460 let Some(delta) = change.score_delta() else {
461 return true;
465 };
466 let bounded = filters.min_score_delta.is_some() || filters.max_score_delta.is_some();
467 if bounded && !delta.is_finite() {
468 return false;
474 }
475 if filters.min_score_delta.is_some_and(|min| delta < min) {
476 return false;
477 }
478 if filters.max_score_delta.is_some_and(|max| delta > max) {
479 return false;
480 }
481 true
482}
483
484fn sort_in_place(shown: &mut [&FunctionChange], key: DeltaSortKey) {
485 match key {
486 DeltaSortKey::ScoreDelta => shown.sort_by(cmp_by_score_delta_desc),
487 DeltaSortKey::CurrentCrap => shown.sort_by(cmp_by_current_crap_desc),
488 DeltaSortKey::BaselineCrap => shown.sort_by(cmp_by_baseline_crap_desc),
489 DeltaSortKey::Path => shown.sort_by(cmp_by_path),
490 }
491}
492
493fn cmp_by_score_delta_desc(a: &&FunctionChange, b: &&FunctionChange) -> Ordering {
499 cmp_f64_desc(signed_impact(a), signed_impact(b))
500}
501
502fn signed_impact(change: &FunctionChange) -> f64 {
503 match change {
504 FunctionChange::Modified { baseline, current } => {
505 current.scored.crap.value - baseline.scored.crap.value
506 }
507 FunctionChange::Added { current } => current.scored.crap.value,
508 FunctionChange::Removed { baseline } => -baseline.scored.crap.value,
509 }
510}
511
512fn cmp_by_current_crap_desc(a: &&FunctionChange, b: &&FunctionChange) -> Ordering {
513 let (rank_a, score_a) = current_score_rank(a);
517 let (rank_b, score_b) = current_score_rank(b);
518 rank_a.cmp(&rank_b).then(cmp_f64_desc(score_a, score_b))
519}
520
521fn current_score_rank(change: &FunctionChange) -> (u8, f64) {
522 match change.current_score() {
523 Some(s) => (0, s),
524 None => (1, 0.0),
525 }
526}
527
528fn cmp_by_baseline_crap_desc(a: &&FunctionChange, b: &&FunctionChange) -> Ordering {
529 let (rank_a, score_a) = baseline_score_rank(a);
530 let (rank_b, score_b) = baseline_score_rank(b);
531 rank_a.cmp(&rank_b).then(cmp_f64_desc(score_a, score_b))
532}
533
534fn baseline_score_rank(change: &FunctionChange) -> (u8, f64) {
535 match change.baseline_score() {
536 Some(s) => (0, s),
537 None => (1, 0.0),
538 }
539}
540
541fn cmp_by_path(a: &&FunctionChange, b: &&FunctionChange) -> Ordering {
542 a.file_path()
543 .cmp(b.file_path())
544 .then_with(|| a.qualified_name().cmp(b.qualified_name()))
545}
546
547fn cmp_f64_desc(a: f64, b: f64) -> Ordering {
550 match (a.is_nan(), b.is_nan()) {
551 (true, true) => Ordering::Equal,
552 (true, false) => Ordering::Greater,
553 (false, true) => Ordering::Less,
554 (false, false) => b.partial_cmp(&a).expect("non-NaN partial_cmp infallible"),
555 }
556}
557
558fn truncate_to(shown: &mut Vec<&FunctionChange>, limit: Option<usize>) -> bool {
559 match limit {
560 Some(n) if n > 0 && shown.len() > n => {
561 shown.truncate(n);
562 true
563 }
564 _ => false,
565 }
566}
567
568#[cfg(test)]
571mod tests {
572 use super::*;
573 use crate::domain::types::{
574 AnalysisSummary, ComplexityMetric, CrapScore, FunctionIdentity, FunctionVerdict,
575 RiskDistribution, RiskLevel, ScoredFunction, SourceSpan,
576 };
577
578 fn make_verdict(file: &str, name: &str, score: f64, exceeds: bool) -> FunctionVerdict {
579 FunctionVerdict {
580 scored: ScoredFunction {
581 identity: FunctionIdentity {
582 file_path: file.to_string(),
583 qualified_name: name.to_string(),
584 span: SourceSpan {
585 start_line: 1,
586 end_line: 5,
587 start_column: 0,
588 end_column: 0,
589 },
590 },
591 complexity: 5,
592 complexity_metric: ComplexityMetric::Cognitive,
593 coverage_percent: 50.0,
594 branch_coverage_percent: None,
595 crap: CrapScore {
596 value: score,
597 risk_level: if score > 30.0 {
598 RiskLevel::High
599 } else if score > 8.0 {
600 RiskLevel::Moderate
601 } else if score > 5.0 {
602 RiskLevel::Acceptable
603 } else {
604 RiskLevel::Low
605 },
606 },
607 contributors: vec![],
608 },
609 threshold: 25.0,
610 exceeds,
611 diagnostic: None,
612 }
613 }
614
615 fn make_result(verdicts: Vec<FunctionVerdict>) -> AnalysisResult {
616 let exceeding = verdicts.iter().filter(|v| v.exceeds).count();
617 let total = verdicts.len();
618 AnalysisResult {
619 functions: verdicts,
620 summary: AnalysisSummary {
621 total_functions: total,
622 total_files: 1,
623 exceeding_threshold: exceeding,
624 average_crap: 0.0,
625 median_crap: 0.0,
626 max_crap: None,
627 worst_function: None,
628 distribution: RiskDistribution {
629 low: 0,
630 acceptable: 0,
631 moderate: 0,
632 high: 0,
633 },
634 ..Default::default()
635 },
636 passed: exceeding == 0,
637 }
638 }
639
640 #[test]
643 fn compute_identity_yields_all_modified_zero_delta() {
644 let result = make_result(vec![
645 make_verdict("a.rs", "alpha", 5.0, false),
646 make_verdict("a.rs", "beta", 12.0, false),
647 make_verdict("b.rs", "gamma", 47.0, true),
648 ]);
649 let delta = compute(result.clone(), result);
650 assert_eq!(delta.changes.len(), 3);
651 for change in &delta.changes {
652 assert!(matches!(change, FunctionChange::Modified { .. }));
653 assert_eq!(change.score_delta(), Some(0.0));
654 }
655 }
656
657 #[test]
658 fn compute_classifies_added_function() {
659 let baseline = make_result(vec![]);
660 let current = make_result(vec![make_verdict("a.rs", "new_fn", 10.0, false)]);
661 let delta = compute(baseline, current);
662 assert_eq!(delta.changes.len(), 1);
663 assert!(matches!(delta.changes[0], FunctionChange::Added { .. }));
664 assert_eq!(delta.changes[0].current_score(), Some(10.0));
665 assert_eq!(delta.changes[0].baseline_score(), None);
666 assert_eq!(delta.changes[0].score_delta(), None);
667 }
668
669 #[test]
670 fn compute_classifies_removed_function() {
671 let baseline = make_result(vec![make_verdict("a.rs", "old_fn", 8.0, false)]);
672 let current = make_result(vec![]);
673 let delta = compute(baseline, current);
674 assert_eq!(delta.changes.len(), 1);
675 assert!(matches!(delta.changes[0], FunctionChange::Removed { .. }));
676 assert_eq!(delta.changes[0].baseline_score(), Some(8.0));
677 assert_eq!(delta.changes[0].current_score(), None);
678 }
679
680 #[test]
681 fn compute_classifies_modified_function() {
682 let baseline = make_result(vec![make_verdict("a.rs", "fn_a", 8.0, false)]);
683 let current = make_result(vec![make_verdict("a.rs", "fn_a", 24.0, false)]);
684 let delta = compute(baseline, current);
685 assert_eq!(delta.changes.len(), 1);
686 assert!(matches!(delta.changes[0], FunctionChange::Modified { .. }));
687 assert_eq!(delta.changes[0].baseline_score(), Some(8.0));
688 assert_eq!(delta.changes[0].current_score(), Some(24.0));
689 assert_eq!(delta.changes[0].score_delta(), Some(16.0));
690 }
691
692 #[test]
693 fn compute_same_name_different_files_are_separate() {
694 let baseline = make_result(vec![make_verdict("a.rs", "log", 5.0, false)]);
695 let current = make_result(vec![make_verdict("b.rs", "log", 5.0, false)]);
696 let delta = compute(baseline, current);
697 assert_eq!(delta.changes.len(), 2);
698 let kinds: Vec<_> = delta.changes.iter().map(|c| c.kind()).collect();
699 assert!(kinds.contains(&ChangeKind::Added));
700 assert!(kinds.contains(&ChangeKind::Removed));
701 }
702
703 #[test]
704 fn compute_same_file_rename_produces_add_remove() {
705 let baseline = make_result(vec![make_verdict("a.rs", "v1", 5.0, false)]);
706 let current = make_result(vec![make_verdict("a.rs", "v2", 5.0, false)]);
707 let delta = compute(baseline, current);
708 assert_eq!(delta.changes.len(), 2);
709 let kinds: Vec<_> = delta.changes.iter().map(|c| c.kind()).collect();
710 assert!(kinds.contains(&ChangeKind::Added));
711 assert!(kinds.contains(&ChangeKind::Removed));
712 }
713
714 #[test]
715 fn compute_ignores_span_when_matching() {
716 let mut baseline_v = make_verdict("a.rs", "fn_a", 5.0, false);
718 baseline_v.scored.identity.span = SourceSpan {
719 start_line: 1,
720 end_line: 5,
721 start_column: 0,
722 end_column: 0,
723 };
724 let mut current_v = make_verdict("a.rs", "fn_a", 5.0, false);
725 current_v.scored.identity.span = SourceSpan {
726 start_line: 100,
727 end_line: 105,
728 start_column: 0,
729 end_column: 0,
730 };
731 let delta = compute(make_result(vec![baseline_v]), make_result(vec![current_v]));
732 assert_eq!(delta.changes.len(), 1);
733 assert!(matches!(delta.changes[0], FunctionChange::Modified { .. }));
734 }
735
736 #[test]
737 fn removed_rows_are_emitted_in_identity_key_order() {
738 let baseline = make_result(vec![
744 make_verdict("zeta.rs", "zeta_fn", 5.0, false),
745 make_verdict("alpha.rs", "alpha_fn", 5.0, false),
746 make_verdict("beta.rs", "beta_fn", 5.0, false),
747 ]);
748 let current = make_result(vec![]);
750 let delta = compute(baseline, current);
751
752 assert_eq!(delta.changes.len(), 3);
755 assert_eq!(delta.changes[0].file_path(), "alpha.rs");
756 assert_eq!(delta.changes[1].file_path(), "beta.rs");
757 assert_eq!(delta.changes[2].file_path(), "zeta.rs");
758 }
759
760 #[test]
763 fn summary_counts_added_removed_modified() {
764 let changes = vec![
765 FunctionChange::Added {
766 current: make_verdict("a.rs", "new", 5.0, false),
767 },
768 FunctionChange::Removed {
769 baseline: make_verdict("a.rs", "old", 5.0, false),
770 },
771 FunctionChange::Modified {
772 baseline: make_verdict("a.rs", "fn_a", 5.0, false),
773 current: make_verdict("a.rs", "fn_a", 8.0, false),
774 },
775 ];
776 let summary = DeltaSummary::compute(&changes);
777 assert_eq!(summary.added, 1);
778 assert_eq!(summary.removed, 1);
779 assert_eq!(summary.modified, 1);
780 }
781
782 #[test]
783 fn summary_regressions_are_modified_with_positive_delta() {
784 let changes = vec![FunctionChange::Modified {
785 baseline: make_verdict("a.rs", "fn_a", 5.0, false),
786 current: make_verdict("a.rs", "fn_a", 10.0, false),
787 }];
788 let summary = DeltaSummary::compute(&changes);
789 assert_eq!(summary.regressions, 1);
790 assert_eq!(summary.improvements, 0);
791 }
792
793 #[test]
794 fn summary_improvements_are_modified_with_negative_delta() {
795 let changes = vec![FunctionChange::Modified {
796 baseline: make_verdict("a.rs", "fn_a", 47.0, true),
797 current: make_verdict("a.rs", "fn_a", 12.0, false),
798 }];
799 let summary = DeltaSummary::compute(&changes);
800 assert_eq!(summary.regressions, 0);
801 assert_eq!(summary.improvements, 1);
802 }
803
804 #[test]
805 fn summary_zero_delta_neither_regression_nor_improvement() {
806 let changes = vec![FunctionChange::Modified {
807 baseline: make_verdict("a.rs", "fn_a", 5.0, false),
808 current: make_verdict("a.rs", "fn_a", 5.0, false),
809 }];
810 let summary = DeltaSummary::compute(&changes);
811 assert_eq!(summary.regressions, 0);
812 assert_eq!(summary.improvements, 0);
813 }
814
815 #[test]
816 fn summary_new_violation_added_function_failing() {
817 let changes = vec![FunctionChange::Added {
818 current: make_verdict("a.rs", "new_bad", 31.0, true),
819 }];
820 let summary = DeltaSummary::compute(&changes);
821 assert_eq!(summary.new_violations, 1);
822 assert!(!summary.passed);
823 }
824
825 #[test]
826 fn summary_new_violation_modified_crossing_threshold() {
827 let changes = vec![FunctionChange::Modified {
828 baseline: make_verdict("a.rs", "fn_a", 8.0, false),
829 current: make_verdict("a.rs", "fn_a", 47.0, true),
830 }];
831 let summary = DeltaSummary::compute(&changes);
832 assert_eq!(summary.new_violations, 1);
833 assert_eq!(summary.regressions, 1);
834 }
835
836 #[test]
837 fn summary_no_new_violation_when_modified_still_passing() {
838 let changes = vec![FunctionChange::Modified {
839 baseline: make_verdict("a.rs", "fn_a", 8.0, false),
840 current: make_verdict("a.rs", "fn_a", 20.0, false),
841 }];
842 let summary = DeltaSummary::compute(&changes);
843 assert_eq!(summary.regressions, 1);
844 assert_eq!(summary.new_violations, 0);
845 assert!(summary.passed);
846 }
847
848 #[test]
849 fn summary_pre_existing_violation_does_not_count_as_new() {
850 let changes = vec![FunctionChange::Modified {
851 baseline: make_verdict("a.rs", "fn_a", 47.0, true),
852 current: make_verdict("a.rs", "fn_a", 60.0, true),
853 }];
854 let summary = DeltaSummary::compute(&changes);
855 assert_eq!(summary.regressions, 1);
856 assert_eq!(summary.new_violations, 0);
857 assert!(summary.passed);
858 }
859
860 #[test]
861 fn summary_added_passing_function_not_a_new_violation() {
862 let changes = vec![FunctionChange::Added {
863 current: make_verdict("a.rs", "new_good", 5.0, false),
864 }];
865 let summary = DeltaSummary::compute(&changes);
866 assert_eq!(summary.added, 1);
867 assert_eq!(summary.new_violations, 0);
868 }
869
870 #[test]
871 fn summary_removed_function_never_counts_as_new_violation() {
872 let changes = vec![FunctionChange::Removed {
873 baseline: make_verdict("a.rs", "old_bad", 47.0, true),
874 }];
875 let summary = DeltaSummary::compute(&changes);
876 assert_eq!(summary.removed, 1);
877 assert_eq!(summary.new_violations, 0);
878 assert!(summary.passed);
879 }
880
881 #[test]
882 fn summary_passed_iff_new_violations_zero() {
883 let zero = DeltaSummary::compute(&[]);
884 assert!(zero.passed);
885
886 let with_new = DeltaSummary::compute(&[FunctionChange::Added {
887 current: make_verdict("a.rs", "bad", 31.0, true),
888 }]);
889 assert!(!with_new.passed);
890 }
891
892 #[test]
895 fn change_kind_serializes_lowercase() {
896 assert_eq!(
897 serde_json::to_string(&ChangeKind::Added).unwrap(),
898 "\"added\""
899 );
900 assert_eq!(
901 serde_json::to_string(&ChangeKind::Modified).unwrap(),
902 "\"modified\""
903 );
904 assert_eq!(
905 serde_json::to_string(&ChangeKind::Removed).unwrap(),
906 "\"removed\""
907 );
908 }
909
910 #[test]
911 fn change_kind_all_contains_every_variant() {
912 assert_eq!(ChangeKind::ALL.len(), 3);
913 assert!(ChangeKind::ALL.contains(&ChangeKind::Added));
914 assert!(ChangeKind::ALL.contains(&ChangeKind::Removed));
915 assert!(ChangeKind::ALL.contains(&ChangeKind::Modified));
916 }
917
918 #[test]
919 fn change_kind_as_str_matches_serde() {
920 for kind in ChangeKind::ALL {
921 let json = serde_json::to_string(&kind).unwrap();
922 let stripped = json.trim_matches('"');
923 assert_eq!(kind.as_str(), stripped);
924 }
925 }
926
927 #[test]
928 fn change_file_path_and_qualified_name_accessors() {
929 let added = FunctionChange::Added {
930 current: make_verdict("src/foo.rs", "module::fn_a", 5.0, false),
931 };
932 assert_eq!(added.file_path(), "src/foo.rs");
933 assert_eq!(added.qualified_name(), "module::fn_a");
934
935 let removed = FunctionChange::Removed {
936 baseline: make_verdict("src/bar.rs", "module::fn_b", 5.0, false),
937 };
938 assert_eq!(removed.file_path(), "src/bar.rs");
939
940 let modified = FunctionChange::Modified {
941 baseline: make_verdict("src/baz.rs", "module::fn_c", 5.0, false),
942 current: make_verdict("src/baz.rs", "module::fn_c", 10.0, false),
943 };
944 assert_eq!(modified.file_path(), "src/baz.rs");
945 }
946
947 #[test]
950 fn analysis_delta_carries_baseline_current_and_changes() {
951 let baseline = make_result(vec![make_verdict("a.rs", "fn_a", 5.0, false)]);
952 let current = make_result(vec![make_verdict("a.rs", "fn_a", 5.0, false)]);
953 let delta = compute(baseline.clone(), current.clone());
954 assert_eq!(delta.baseline.functions.len(), baseline.functions.len());
955 assert_eq!(delta.current.functions.len(), current.functions.len());
956 assert_eq!(delta.changes.len(), 1);
957 }
958
959 #[test]
960 fn empty_inputs_produce_empty_delta() {
961 let delta = compute(make_result(vec![]), make_result(vec![]));
962 assert!(delta.changes.is_empty());
963 let summary = DeltaSummary::compute(&delta.changes);
964 assert_eq!(summary.added, 0);
965 assert_eq!(summary.removed, 0);
966 assert_eq!(summary.modified, 0);
967 assert!(summary.passed);
968 }
969
970 fn delta_with_changes(changes: Vec<FunctionChange>) -> AnalysisDelta {
973 AnalysisDelta {
974 baseline: make_result(vec![]),
975 current: make_result(vec![]),
976 summary: DeltaSummary::compute(&changes),
977 changes,
978 }
979 }
980
981 #[test]
982 fn apply_default_spec_returns_all_changes() {
983 let delta = delta_with_changes(vec![
984 FunctionChange::Added {
985 current: make_verdict("a.rs", "x", 31.0, true),
986 },
987 FunctionChange::Modified {
988 baseline: make_verdict("a.rs", "y", 5.0, false),
989 current: make_verdict("a.rs", "y", 10.0, false),
990 },
991 ]);
992 let view = apply(&delta, DeltaViewSpec::default());
993 assert_eq!(view.shown.len(), 2);
994 assert_eq!(view.eligible_count, 2);
995 assert!(!view.truncated);
996 }
997
998 #[test]
999 fn apply_default_sorts_by_signed_impact_descending() {
1000 let delta = delta_with_changes(vec![
1002 FunctionChange::Modified {
1003 baseline: make_verdict("a.rs", "small_mod", 5.0, false),
1004 current: make_verdict("a.rs", "small_mod", 6.0, false),
1005 },
1006 FunctionChange::Modified {
1007 baseline: make_verdict("a.rs", "big_mod", 5.0, false),
1008 current: make_verdict("a.rs", "big_mod", 25.0, false),
1009 },
1010 FunctionChange::Added {
1011 current: make_verdict("a.rs", "big_added", 31.0, true),
1012 },
1013 ]);
1014 let view = apply(&delta, DeltaViewSpec::default());
1015 assert_eq!(view.shown[0].qualified_name(), "big_added");
1016 assert_eq!(view.shown[1].qualified_name(), "big_mod");
1017 assert_eq!(view.shown[2].qualified_name(), "small_mod");
1018 }
1019
1020 #[test]
1021 fn apply_default_sort_puts_regressions_above_improvements() {
1022 let delta = delta_with_changes(vec![
1029 FunctionChange::Modified {
1030 baseline: make_verdict("a.rs", "big_improvement", 30.0, true),
1031 current: make_verdict("a.rs", "big_improvement", 5.0, false),
1032 },
1033 FunctionChange::Modified {
1034 baseline: make_verdict("a.rs", "small_regression", 5.0, false),
1035 current: make_verdict("a.rs", "small_regression", 10.0, false),
1036 },
1037 FunctionChange::Removed {
1038 baseline: make_verdict("a.rs", "big_removed", 30.0, true),
1039 },
1040 FunctionChange::Added {
1041 current: make_verdict("a.rs", "big_added", 10.0, false),
1042 },
1043 ]);
1044 let view = apply(&delta, DeltaViewSpec::default());
1045 assert_eq!(view.shown[0].qualified_name(), "big_added"); assert_eq!(view.shown[1].qualified_name(), "small_regression"); assert_eq!(view.shown[2].qualified_name(), "big_improvement"); assert_eq!(view.shown[3].qualified_name(), "big_removed"); }
1050
1051 #[test]
1052 fn apply_filter_change_kinds_added_only() {
1053 let delta = delta_with_changes(vec![
1054 FunctionChange::Added {
1055 current: make_verdict("a.rs", "added_one", 5.0, false),
1056 },
1057 FunctionChange::Removed {
1058 baseline: make_verdict("a.rs", "removed_one", 5.0, false),
1059 },
1060 FunctionChange::Modified {
1061 baseline: make_verdict("a.rs", "mod_one", 5.0, false),
1062 current: make_verdict("a.rs", "mod_one", 6.0, false),
1063 },
1064 ]);
1065 let mut kinds = BTreeSet::new();
1066 kinds.insert(ChangeKind::Added);
1067 let spec = DeltaViewSpec {
1068 filters: DeltaFilters {
1069 change_kinds: Some(kinds),
1070 ..Default::default()
1071 },
1072 ..Default::default()
1073 };
1074 let view = apply(&delta, spec);
1075 assert_eq!(view.shown.len(), 1);
1076 assert_eq!(view.shown[0].kind(), ChangeKind::Added);
1077 }
1078
1079 #[test]
1080 fn apply_filter_score_delta_min_excludes_below() {
1081 let delta = delta_with_changes(vec![
1082 FunctionChange::Modified {
1083 baseline: make_verdict("a.rs", "tiny", 5.0, false),
1084 current: make_verdict("a.rs", "tiny", 6.0, false),
1085 },
1086 FunctionChange::Modified {
1087 baseline: make_verdict("a.rs", "big", 5.0, false),
1088 current: make_verdict("a.rs", "big", 25.0, false),
1089 },
1090 ]);
1091 let spec = DeltaViewSpec {
1092 filters: DeltaFilters {
1093 min_score_delta: Some(10.0),
1094 ..Default::default()
1095 },
1096 ..Default::default()
1097 };
1098 let view = apply(&delta, spec);
1099 assert_eq!(view.shown.len(), 1);
1100 assert_eq!(view.shown[0].qualified_name(), "big");
1101 }
1102
1103 #[test]
1104 fn apply_filter_score_delta_passes_added_and_removed() {
1105 let delta = delta_with_changes(vec![
1107 FunctionChange::Added {
1108 current: make_verdict("a.rs", "added_one", 5.0, false),
1109 },
1110 FunctionChange::Removed {
1111 baseline: make_verdict("a.rs", "removed_one", 5.0, false),
1112 },
1113 ]);
1114 let spec = DeltaViewSpec {
1115 filters: DeltaFilters {
1116 min_score_delta: Some(100.0),
1117 ..Default::default()
1118 },
1119 ..Default::default()
1120 };
1121 let view = apply(&delta, spec);
1122 assert_eq!(view.shown.len(), 2);
1123 }
1124
1125 #[test]
1126 fn apply_sort_current_crap_descending_removed_last() {
1127 let delta = delta_with_changes(vec![
1128 FunctionChange::Modified {
1129 baseline: make_verdict("a.rs", "modlow", 50.0, true),
1130 current: make_verdict("a.rs", "modlow", 5.0, false),
1131 },
1132 FunctionChange::Removed {
1133 baseline: make_verdict("a.rs", "removed_top", 999.0, true),
1134 },
1135 FunctionChange::Added {
1136 current: make_verdict("a.rs", "added_high", 47.0, true),
1137 },
1138 ]);
1139 let spec = DeltaViewSpec {
1140 sort: DeltaSortKey::CurrentCrap,
1141 ..Default::default()
1142 };
1143 let view = apply(&delta, spec);
1144 assert_eq!(view.shown[0].qualified_name(), "added_high"); assert_eq!(view.shown[1].qualified_name(), "modlow"); assert_eq!(view.shown[2].qualified_name(), "removed_top"); }
1148
1149 #[test]
1150 fn apply_sort_path_alphabetical() {
1151 let delta = delta_with_changes(vec![
1152 FunctionChange::Modified {
1153 baseline: make_verdict("zzz.rs", "z", 5.0, false),
1154 current: make_verdict("zzz.rs", "z", 6.0, false),
1155 },
1156 FunctionChange::Modified {
1157 baseline: make_verdict("aaa.rs", "a", 5.0, false),
1158 current: make_verdict("aaa.rs", "a", 6.0, false),
1159 },
1160 FunctionChange::Modified {
1161 baseline: make_verdict("mmm.rs", "m", 5.0, false),
1162 current: make_verdict("mmm.rs", "m", 6.0, false),
1163 },
1164 ]);
1165 let spec = DeltaViewSpec {
1166 sort: DeltaSortKey::Path,
1167 ..Default::default()
1168 };
1169 let view = apply(&delta, spec);
1170 assert_eq!(view.shown[0].file_path(), "aaa.rs");
1171 assert_eq!(view.shown[1].file_path(), "mmm.rs");
1172 assert_eq!(view.shown[2].file_path(), "zzz.rs");
1173 }
1174
1175 #[test]
1176 fn apply_truncate_marks_truncated_true() {
1177 let changes: Vec<FunctionChange> = (0..10)
1178 .map(|i| FunctionChange::Modified {
1179 baseline: make_verdict("a.rs", &format!("fn_{i}"), 5.0, false),
1180 current: make_verdict("a.rs", &format!("fn_{i}"), 5.0 + i as f64, false),
1181 })
1182 .collect();
1183 let delta = delta_with_changes(changes);
1184 let spec = DeltaViewSpec {
1185 limit: Some(3),
1186 ..Default::default()
1187 };
1188 let view = apply(&delta, spec);
1189 assert_eq!(view.shown.len(), 3);
1190 assert_eq!(view.eligible_count, 10);
1191 assert!(view.truncated);
1192 }
1193
1194 #[test]
1195 fn apply_truncate_zero_means_no_limit() {
1196 let changes: Vec<FunctionChange> = (0..3)
1197 .map(|i| FunctionChange::Added {
1198 current: make_verdict("a.rs", &format!("fn_{i}"), 5.0, false),
1199 })
1200 .collect();
1201 let delta = delta_with_changes(changes);
1202 let spec = DeltaViewSpec {
1203 limit: Some(0),
1204 ..Default::default()
1205 };
1206 let view = apply(&delta, spec);
1207 assert_eq!(view.shown.len(), 3);
1208 assert!(!view.truncated);
1209 }
1210
1211 #[test]
1212 fn apply_view_full_borrows_underlying_delta() {
1213 let delta = delta_with_changes(vec![]);
1214 let view = apply(&delta, DeltaViewSpec::default());
1215 assert!(std::ptr::eq(view.full, &delta));
1216 }
1217}
1218
1219#[cfg(test)]
1222mod proptests {
1223 use super::*;
1224 use crate::test_strategies::arb_analysis_result;
1225 use proptest::prelude::*;
1226
1227 proptest! {
1228 #[test]
1231 fn prop_compute_identity_yields_all_modified_zero(result in arb_analysis_result()) {
1232 let n = result.functions.len();
1233 let delta = compute(result.clone(), result);
1234 prop_assert_eq!(delta.changes.len(), n);
1235 for change in &delta.changes {
1236 let is_modified = matches!(change, FunctionChange::Modified { .. });
1237 prop_assert!(is_modified);
1238 prop_assert_eq!(change.score_delta(), Some(0.0));
1239 }
1240 let summary = DeltaSummary::compute(&delta.changes);
1241 prop_assert_eq!(summary.added, 0);
1242 prop_assert_eq!(summary.removed, 0);
1243 prop_assert_eq!(summary.modified, n as u32);
1244 prop_assert_eq!(summary.regressions, 0);
1245 prop_assert_eq!(summary.improvements, 0);
1246 prop_assert_eq!(summary.new_violations, 0);
1247 prop_assert!(summary.passed);
1248 }
1249
1250 #[test]
1253 fn prop_changes_count_bounded(
1254 baseline in arb_analysis_result(),
1255 current in arb_analysis_result(),
1256 ) {
1257 let baseline_len = baseline.functions.len();
1258 let current_len = current.functions.len();
1259 let delta = compute(baseline, current);
1260 let n = delta.changes.len();
1261 prop_assert!(n <= baseline_len + current_len);
1266 let modified_count = delta
1268 .changes
1269 .iter()
1270 .filter(|c| matches!(c, FunctionChange::Modified { .. }))
1271 .count();
1272 prop_assert!(modified_count <= baseline_len);
1273 prop_assert!(modified_count <= current_len);
1274 }
1275
1276 #[test]
1279 fn prop_new_violations_well_bounded(
1280 baseline in arb_analysis_result(),
1281 current in arb_analysis_result(),
1282 ) {
1283 let delta = compute(baseline, current);
1284 let summary = DeltaSummary::compute(&delta.changes);
1285 prop_assert!(summary.new_violations <= summary.added + summary.modified);
1286 prop_assert_eq!(summary.passed, summary.new_violations == 0);
1287 }
1288
1289 #[test]
1293 fn prop_view_shown_subset_of_changes(
1294 baseline in arb_analysis_result(),
1295 current in arb_analysis_result(),
1296 ) {
1297 let delta = compute(baseline, current);
1298 let view = apply(&delta, DeltaViewSpec::default());
1299 prop_assert!(view.shown.len() <= view.eligible_count);
1300 prop_assert!(view.eligible_count <= delta.changes.len());
1301 prop_assert_eq!(view.shown.len() == view.eligible_count, !view.truncated);
1302 }
1303
1304 #[test]
1307 fn prop_apply_does_not_mutate_summary(
1308 baseline in arb_analysis_result(),
1309 current in arb_analysis_result(),
1310 ) {
1311 let delta = compute(baseline, current);
1312 let original_passed = delta.summary.passed;
1313 let view = apply(&delta, DeltaViewSpec::default());
1314 prop_assert_eq!(view.full.summary.passed, original_passed);
1315 }
1316 }
1317}