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 crap: CrapScore {
595 value: score,
596 risk_level: if score > 30.0 {
597 RiskLevel::High
598 } else if score > 8.0 {
599 RiskLevel::Moderate
600 } else if score > 5.0 {
601 RiskLevel::Acceptable
602 } else {
603 RiskLevel::Low
604 },
605 },
606 contributors: vec![],
607 },
608 threshold: 25.0,
609 exceeds,
610 diagnostic: None,
611 }
612 }
613
614 fn make_result(verdicts: Vec<FunctionVerdict>) -> AnalysisResult {
615 let exceeding = verdicts.iter().filter(|v| v.exceeds).count();
616 let total = verdicts.len();
617 AnalysisResult {
618 functions: verdicts,
619 summary: AnalysisSummary {
620 total_functions: total,
621 total_files: 1,
622 exceeding_threshold: exceeding,
623 average_crap: 0.0,
624 median_crap: 0.0,
625 max_crap: None,
626 worst_function: None,
627 distribution: RiskDistribution {
628 low: 0,
629 acceptable: 0,
630 moderate: 0,
631 high: 0,
632 },
633 ..Default::default()
634 },
635 passed: exceeding == 0,
636 }
637 }
638
639 #[test]
642 fn compute_identity_yields_all_modified_zero_delta() {
643 let result = make_result(vec![
644 make_verdict("a.rs", "alpha", 5.0, false),
645 make_verdict("a.rs", "beta", 12.0, false),
646 make_verdict("b.rs", "gamma", 47.0, true),
647 ]);
648 let delta = compute(result.clone(), result);
649 assert_eq!(delta.changes.len(), 3);
650 for change in &delta.changes {
651 assert!(matches!(change, FunctionChange::Modified { .. }));
652 assert_eq!(change.score_delta(), Some(0.0));
653 }
654 }
655
656 #[test]
657 fn compute_classifies_added_function() {
658 let baseline = make_result(vec![]);
659 let current = make_result(vec![make_verdict("a.rs", "new_fn", 10.0, false)]);
660 let delta = compute(baseline, current);
661 assert_eq!(delta.changes.len(), 1);
662 assert!(matches!(delta.changes[0], FunctionChange::Added { .. }));
663 assert_eq!(delta.changes[0].current_score(), Some(10.0));
664 assert_eq!(delta.changes[0].baseline_score(), None);
665 assert_eq!(delta.changes[0].score_delta(), None);
666 }
667
668 #[test]
669 fn compute_classifies_removed_function() {
670 let baseline = make_result(vec![make_verdict("a.rs", "old_fn", 8.0, false)]);
671 let current = make_result(vec![]);
672 let delta = compute(baseline, current);
673 assert_eq!(delta.changes.len(), 1);
674 assert!(matches!(delta.changes[0], FunctionChange::Removed { .. }));
675 assert_eq!(delta.changes[0].baseline_score(), Some(8.0));
676 assert_eq!(delta.changes[0].current_score(), None);
677 }
678
679 #[test]
680 fn compute_classifies_modified_function() {
681 let baseline = make_result(vec![make_verdict("a.rs", "fn_a", 8.0, false)]);
682 let current = make_result(vec![make_verdict("a.rs", "fn_a", 24.0, false)]);
683 let delta = compute(baseline, current);
684 assert_eq!(delta.changes.len(), 1);
685 assert!(matches!(delta.changes[0], FunctionChange::Modified { .. }));
686 assert_eq!(delta.changes[0].baseline_score(), Some(8.0));
687 assert_eq!(delta.changes[0].current_score(), Some(24.0));
688 assert_eq!(delta.changes[0].score_delta(), Some(16.0));
689 }
690
691 #[test]
692 fn compute_same_name_different_files_are_separate() {
693 let baseline = make_result(vec![make_verdict("a.rs", "log", 5.0, false)]);
694 let current = make_result(vec![make_verdict("b.rs", "log", 5.0, false)]);
695 let delta = compute(baseline, current);
696 assert_eq!(delta.changes.len(), 2);
697 let kinds: Vec<_> = delta.changes.iter().map(|c| c.kind()).collect();
698 assert!(kinds.contains(&ChangeKind::Added));
699 assert!(kinds.contains(&ChangeKind::Removed));
700 }
701
702 #[test]
703 fn compute_same_file_rename_produces_add_remove() {
704 let baseline = make_result(vec![make_verdict("a.rs", "v1", 5.0, false)]);
705 let current = make_result(vec![make_verdict("a.rs", "v2", 5.0, false)]);
706 let delta = compute(baseline, current);
707 assert_eq!(delta.changes.len(), 2);
708 let kinds: Vec<_> = delta.changes.iter().map(|c| c.kind()).collect();
709 assert!(kinds.contains(&ChangeKind::Added));
710 assert!(kinds.contains(&ChangeKind::Removed));
711 }
712
713 #[test]
714 fn compute_ignores_span_when_matching() {
715 let mut baseline_v = make_verdict("a.rs", "fn_a", 5.0, false);
717 baseline_v.scored.identity.span = SourceSpan {
718 start_line: 1,
719 end_line: 5,
720 start_column: 0,
721 end_column: 0,
722 };
723 let mut current_v = make_verdict("a.rs", "fn_a", 5.0, false);
724 current_v.scored.identity.span = SourceSpan {
725 start_line: 100,
726 end_line: 105,
727 start_column: 0,
728 end_column: 0,
729 };
730 let delta = compute(make_result(vec![baseline_v]), make_result(vec![current_v]));
731 assert_eq!(delta.changes.len(), 1);
732 assert!(matches!(delta.changes[0], FunctionChange::Modified { .. }));
733 }
734
735 #[test]
736 fn removed_rows_are_emitted_in_identity_key_order() {
737 let baseline = make_result(vec![
743 make_verdict("zeta.rs", "zeta_fn", 5.0, false),
744 make_verdict("alpha.rs", "alpha_fn", 5.0, false),
745 make_verdict("beta.rs", "beta_fn", 5.0, false),
746 ]);
747 let current = make_result(vec![]);
749 let delta = compute(baseline, current);
750
751 assert_eq!(delta.changes.len(), 3);
754 assert_eq!(delta.changes[0].file_path(), "alpha.rs");
755 assert_eq!(delta.changes[1].file_path(), "beta.rs");
756 assert_eq!(delta.changes[2].file_path(), "zeta.rs");
757 }
758
759 #[test]
762 fn summary_counts_added_removed_modified() {
763 let changes = vec![
764 FunctionChange::Added {
765 current: make_verdict("a.rs", "new", 5.0, false),
766 },
767 FunctionChange::Removed {
768 baseline: make_verdict("a.rs", "old", 5.0, false),
769 },
770 FunctionChange::Modified {
771 baseline: make_verdict("a.rs", "fn_a", 5.0, false),
772 current: make_verdict("a.rs", "fn_a", 8.0, false),
773 },
774 ];
775 let summary = DeltaSummary::compute(&changes);
776 assert_eq!(summary.added, 1);
777 assert_eq!(summary.removed, 1);
778 assert_eq!(summary.modified, 1);
779 }
780
781 #[test]
782 fn summary_regressions_are_modified_with_positive_delta() {
783 let changes = vec![FunctionChange::Modified {
784 baseline: make_verdict("a.rs", "fn_a", 5.0, false),
785 current: make_verdict("a.rs", "fn_a", 10.0, false),
786 }];
787 let summary = DeltaSummary::compute(&changes);
788 assert_eq!(summary.regressions, 1);
789 assert_eq!(summary.improvements, 0);
790 }
791
792 #[test]
793 fn summary_improvements_are_modified_with_negative_delta() {
794 let changes = vec![FunctionChange::Modified {
795 baseline: make_verdict("a.rs", "fn_a", 47.0, true),
796 current: make_verdict("a.rs", "fn_a", 12.0, false),
797 }];
798 let summary = DeltaSummary::compute(&changes);
799 assert_eq!(summary.regressions, 0);
800 assert_eq!(summary.improvements, 1);
801 }
802
803 #[test]
804 fn summary_zero_delta_neither_regression_nor_improvement() {
805 let changes = vec![FunctionChange::Modified {
806 baseline: make_verdict("a.rs", "fn_a", 5.0, false),
807 current: make_verdict("a.rs", "fn_a", 5.0, false),
808 }];
809 let summary = DeltaSummary::compute(&changes);
810 assert_eq!(summary.regressions, 0);
811 assert_eq!(summary.improvements, 0);
812 }
813
814 #[test]
815 fn summary_new_violation_added_function_failing() {
816 let changes = vec![FunctionChange::Added {
817 current: make_verdict("a.rs", "new_bad", 31.0, true),
818 }];
819 let summary = DeltaSummary::compute(&changes);
820 assert_eq!(summary.new_violations, 1);
821 assert!(!summary.passed);
822 }
823
824 #[test]
825 fn summary_new_violation_modified_crossing_threshold() {
826 let changes = vec![FunctionChange::Modified {
827 baseline: make_verdict("a.rs", "fn_a", 8.0, false),
828 current: make_verdict("a.rs", "fn_a", 47.0, true),
829 }];
830 let summary = DeltaSummary::compute(&changes);
831 assert_eq!(summary.new_violations, 1);
832 assert_eq!(summary.regressions, 1);
833 }
834
835 #[test]
836 fn summary_no_new_violation_when_modified_still_passing() {
837 let changes = vec![FunctionChange::Modified {
838 baseline: make_verdict("a.rs", "fn_a", 8.0, false),
839 current: make_verdict("a.rs", "fn_a", 20.0, false),
840 }];
841 let summary = DeltaSummary::compute(&changes);
842 assert_eq!(summary.regressions, 1);
843 assert_eq!(summary.new_violations, 0);
844 assert!(summary.passed);
845 }
846
847 #[test]
848 fn summary_pre_existing_violation_does_not_count_as_new() {
849 let changes = vec![FunctionChange::Modified {
850 baseline: make_verdict("a.rs", "fn_a", 47.0, true),
851 current: make_verdict("a.rs", "fn_a", 60.0, true),
852 }];
853 let summary = DeltaSummary::compute(&changes);
854 assert_eq!(summary.regressions, 1);
855 assert_eq!(summary.new_violations, 0);
856 assert!(summary.passed);
857 }
858
859 #[test]
860 fn summary_added_passing_function_not_a_new_violation() {
861 let changes = vec![FunctionChange::Added {
862 current: make_verdict("a.rs", "new_good", 5.0, false),
863 }];
864 let summary = DeltaSummary::compute(&changes);
865 assert_eq!(summary.added, 1);
866 assert_eq!(summary.new_violations, 0);
867 }
868
869 #[test]
870 fn summary_removed_function_never_counts_as_new_violation() {
871 let changes = vec![FunctionChange::Removed {
872 baseline: make_verdict("a.rs", "old_bad", 47.0, true),
873 }];
874 let summary = DeltaSummary::compute(&changes);
875 assert_eq!(summary.removed, 1);
876 assert_eq!(summary.new_violations, 0);
877 assert!(summary.passed);
878 }
879
880 #[test]
881 fn summary_passed_iff_new_violations_zero() {
882 let zero = DeltaSummary::compute(&[]);
883 assert!(zero.passed);
884
885 let with_new = DeltaSummary::compute(&[FunctionChange::Added {
886 current: make_verdict("a.rs", "bad", 31.0, true),
887 }]);
888 assert!(!with_new.passed);
889 }
890
891 #[test]
894 fn change_kind_serializes_lowercase() {
895 assert_eq!(
896 serde_json::to_string(&ChangeKind::Added).unwrap(),
897 "\"added\""
898 );
899 assert_eq!(
900 serde_json::to_string(&ChangeKind::Modified).unwrap(),
901 "\"modified\""
902 );
903 assert_eq!(
904 serde_json::to_string(&ChangeKind::Removed).unwrap(),
905 "\"removed\""
906 );
907 }
908
909 #[test]
910 fn change_kind_all_contains_every_variant() {
911 assert_eq!(ChangeKind::ALL.len(), 3);
912 assert!(ChangeKind::ALL.contains(&ChangeKind::Added));
913 assert!(ChangeKind::ALL.contains(&ChangeKind::Removed));
914 assert!(ChangeKind::ALL.contains(&ChangeKind::Modified));
915 }
916
917 #[test]
918 fn change_kind_as_str_matches_serde() {
919 for kind in ChangeKind::ALL {
920 let json = serde_json::to_string(&kind).unwrap();
921 let stripped = json.trim_matches('"');
922 assert_eq!(kind.as_str(), stripped);
923 }
924 }
925
926 #[test]
927 fn change_file_path_and_qualified_name_accessors() {
928 let added = FunctionChange::Added {
929 current: make_verdict("src/foo.rs", "module::fn_a", 5.0, false),
930 };
931 assert_eq!(added.file_path(), "src/foo.rs");
932 assert_eq!(added.qualified_name(), "module::fn_a");
933
934 let removed = FunctionChange::Removed {
935 baseline: make_verdict("src/bar.rs", "module::fn_b", 5.0, false),
936 };
937 assert_eq!(removed.file_path(), "src/bar.rs");
938
939 let modified = FunctionChange::Modified {
940 baseline: make_verdict("src/baz.rs", "module::fn_c", 5.0, false),
941 current: make_verdict("src/baz.rs", "module::fn_c", 10.0, false),
942 };
943 assert_eq!(modified.file_path(), "src/baz.rs");
944 }
945
946 #[test]
949 fn analysis_delta_carries_baseline_current_and_changes() {
950 let baseline = make_result(vec![make_verdict("a.rs", "fn_a", 5.0, false)]);
951 let current = make_result(vec![make_verdict("a.rs", "fn_a", 5.0, false)]);
952 let delta = compute(baseline.clone(), current.clone());
953 assert_eq!(delta.baseline.functions.len(), baseline.functions.len());
954 assert_eq!(delta.current.functions.len(), current.functions.len());
955 assert_eq!(delta.changes.len(), 1);
956 }
957
958 #[test]
959 fn empty_inputs_produce_empty_delta() {
960 let delta = compute(make_result(vec![]), make_result(vec![]));
961 assert!(delta.changes.is_empty());
962 let summary = DeltaSummary::compute(&delta.changes);
963 assert_eq!(summary.added, 0);
964 assert_eq!(summary.removed, 0);
965 assert_eq!(summary.modified, 0);
966 assert!(summary.passed);
967 }
968
969 fn delta_with_changes(changes: Vec<FunctionChange>) -> AnalysisDelta {
972 AnalysisDelta {
973 baseline: make_result(vec![]),
974 current: make_result(vec![]),
975 summary: DeltaSummary::compute(&changes),
976 changes,
977 }
978 }
979
980 #[test]
981 fn apply_default_spec_returns_all_changes() {
982 let delta = delta_with_changes(vec![
983 FunctionChange::Added {
984 current: make_verdict("a.rs", "x", 31.0, true),
985 },
986 FunctionChange::Modified {
987 baseline: make_verdict("a.rs", "y", 5.0, false),
988 current: make_verdict("a.rs", "y", 10.0, false),
989 },
990 ]);
991 let view = apply(&delta, DeltaViewSpec::default());
992 assert_eq!(view.shown.len(), 2);
993 assert_eq!(view.eligible_count, 2);
994 assert!(!view.truncated);
995 }
996
997 #[test]
998 fn apply_default_sorts_by_signed_impact_descending() {
999 let delta = delta_with_changes(vec![
1001 FunctionChange::Modified {
1002 baseline: make_verdict("a.rs", "small_mod", 5.0, false),
1003 current: make_verdict("a.rs", "small_mod", 6.0, false),
1004 },
1005 FunctionChange::Modified {
1006 baseline: make_verdict("a.rs", "big_mod", 5.0, false),
1007 current: make_verdict("a.rs", "big_mod", 25.0, false),
1008 },
1009 FunctionChange::Added {
1010 current: make_verdict("a.rs", "big_added", 31.0, true),
1011 },
1012 ]);
1013 let view = apply(&delta, DeltaViewSpec::default());
1014 assert_eq!(view.shown[0].qualified_name(), "big_added");
1015 assert_eq!(view.shown[1].qualified_name(), "big_mod");
1016 assert_eq!(view.shown[2].qualified_name(), "small_mod");
1017 }
1018
1019 #[test]
1020 fn apply_default_sort_puts_regressions_above_improvements() {
1021 let delta = delta_with_changes(vec![
1028 FunctionChange::Modified {
1029 baseline: make_verdict("a.rs", "big_improvement", 30.0, true),
1030 current: make_verdict("a.rs", "big_improvement", 5.0, false),
1031 },
1032 FunctionChange::Modified {
1033 baseline: make_verdict("a.rs", "small_regression", 5.0, false),
1034 current: make_verdict("a.rs", "small_regression", 10.0, false),
1035 },
1036 FunctionChange::Removed {
1037 baseline: make_verdict("a.rs", "big_removed", 30.0, true),
1038 },
1039 FunctionChange::Added {
1040 current: make_verdict("a.rs", "big_added", 10.0, false),
1041 },
1042 ]);
1043 let view = apply(&delta, DeltaViewSpec::default());
1044 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"); }
1049
1050 #[test]
1051 fn apply_filter_change_kinds_added_only() {
1052 let delta = delta_with_changes(vec![
1053 FunctionChange::Added {
1054 current: make_verdict("a.rs", "added_one", 5.0, false),
1055 },
1056 FunctionChange::Removed {
1057 baseline: make_verdict("a.rs", "removed_one", 5.0, false),
1058 },
1059 FunctionChange::Modified {
1060 baseline: make_verdict("a.rs", "mod_one", 5.0, false),
1061 current: make_verdict("a.rs", "mod_one", 6.0, false),
1062 },
1063 ]);
1064 let mut kinds = BTreeSet::new();
1065 kinds.insert(ChangeKind::Added);
1066 let spec = DeltaViewSpec {
1067 filters: DeltaFilters {
1068 change_kinds: Some(kinds),
1069 ..Default::default()
1070 },
1071 ..Default::default()
1072 };
1073 let view = apply(&delta, spec);
1074 assert_eq!(view.shown.len(), 1);
1075 assert_eq!(view.shown[0].kind(), ChangeKind::Added);
1076 }
1077
1078 #[test]
1079 fn apply_filter_score_delta_min_excludes_below() {
1080 let delta = delta_with_changes(vec![
1081 FunctionChange::Modified {
1082 baseline: make_verdict("a.rs", "tiny", 5.0, false),
1083 current: make_verdict("a.rs", "tiny", 6.0, false),
1084 },
1085 FunctionChange::Modified {
1086 baseline: make_verdict("a.rs", "big", 5.0, false),
1087 current: make_verdict("a.rs", "big", 25.0, false),
1088 },
1089 ]);
1090 let spec = DeltaViewSpec {
1091 filters: DeltaFilters {
1092 min_score_delta: Some(10.0),
1093 ..Default::default()
1094 },
1095 ..Default::default()
1096 };
1097 let view = apply(&delta, spec);
1098 assert_eq!(view.shown.len(), 1);
1099 assert_eq!(view.shown[0].qualified_name(), "big");
1100 }
1101
1102 #[test]
1103 fn apply_filter_score_delta_passes_added_and_removed() {
1104 let delta = delta_with_changes(vec![
1106 FunctionChange::Added {
1107 current: make_verdict("a.rs", "added_one", 5.0, false),
1108 },
1109 FunctionChange::Removed {
1110 baseline: make_verdict("a.rs", "removed_one", 5.0, false),
1111 },
1112 ]);
1113 let spec = DeltaViewSpec {
1114 filters: DeltaFilters {
1115 min_score_delta: Some(100.0),
1116 ..Default::default()
1117 },
1118 ..Default::default()
1119 };
1120 let view = apply(&delta, spec);
1121 assert_eq!(view.shown.len(), 2);
1122 }
1123
1124 #[test]
1125 fn apply_sort_current_crap_descending_removed_last() {
1126 let delta = delta_with_changes(vec![
1127 FunctionChange::Modified {
1128 baseline: make_verdict("a.rs", "modlow", 50.0, true),
1129 current: make_verdict("a.rs", "modlow", 5.0, false),
1130 },
1131 FunctionChange::Removed {
1132 baseline: make_verdict("a.rs", "removed_top", 999.0, true),
1133 },
1134 FunctionChange::Added {
1135 current: make_verdict("a.rs", "added_high", 47.0, true),
1136 },
1137 ]);
1138 let spec = DeltaViewSpec {
1139 sort: DeltaSortKey::CurrentCrap,
1140 ..Default::default()
1141 };
1142 let view = apply(&delta, spec);
1143 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"); }
1147
1148 #[test]
1149 fn apply_sort_path_alphabetical() {
1150 let delta = delta_with_changes(vec![
1151 FunctionChange::Modified {
1152 baseline: make_verdict("zzz.rs", "z", 5.0, false),
1153 current: make_verdict("zzz.rs", "z", 6.0, false),
1154 },
1155 FunctionChange::Modified {
1156 baseline: make_verdict("aaa.rs", "a", 5.0, false),
1157 current: make_verdict("aaa.rs", "a", 6.0, false),
1158 },
1159 FunctionChange::Modified {
1160 baseline: make_verdict("mmm.rs", "m", 5.0, false),
1161 current: make_verdict("mmm.rs", "m", 6.0, false),
1162 },
1163 ]);
1164 let spec = DeltaViewSpec {
1165 sort: DeltaSortKey::Path,
1166 ..Default::default()
1167 };
1168 let view = apply(&delta, spec);
1169 assert_eq!(view.shown[0].file_path(), "aaa.rs");
1170 assert_eq!(view.shown[1].file_path(), "mmm.rs");
1171 assert_eq!(view.shown[2].file_path(), "zzz.rs");
1172 }
1173
1174 #[test]
1175 fn apply_truncate_marks_truncated_true() {
1176 let changes: Vec<FunctionChange> = (0..10)
1177 .map(|i| FunctionChange::Modified {
1178 baseline: make_verdict("a.rs", &format!("fn_{i}"), 5.0, false),
1179 current: make_verdict("a.rs", &format!("fn_{i}"), 5.0 + i as f64, false),
1180 })
1181 .collect();
1182 let delta = delta_with_changes(changes);
1183 let spec = DeltaViewSpec {
1184 limit: Some(3),
1185 ..Default::default()
1186 };
1187 let view = apply(&delta, spec);
1188 assert_eq!(view.shown.len(), 3);
1189 assert_eq!(view.eligible_count, 10);
1190 assert!(view.truncated);
1191 }
1192
1193 #[test]
1194 fn apply_truncate_zero_means_no_limit() {
1195 let changes: Vec<FunctionChange> = (0..3)
1196 .map(|i| FunctionChange::Added {
1197 current: make_verdict("a.rs", &format!("fn_{i}"), 5.0, false),
1198 })
1199 .collect();
1200 let delta = delta_with_changes(changes);
1201 let spec = DeltaViewSpec {
1202 limit: Some(0),
1203 ..Default::default()
1204 };
1205 let view = apply(&delta, spec);
1206 assert_eq!(view.shown.len(), 3);
1207 assert!(!view.truncated);
1208 }
1209
1210 #[test]
1211 fn apply_view_full_borrows_underlying_delta() {
1212 let delta = delta_with_changes(vec![]);
1213 let view = apply(&delta, DeltaViewSpec::default());
1214 assert!(std::ptr::eq(view.full, &delta));
1215 }
1216}
1217
1218#[cfg(test)]
1221mod proptests {
1222 use super::*;
1223 use crate::test_strategies::arb_analysis_result;
1224 use proptest::prelude::*;
1225
1226 proptest! {
1227 #[test]
1230 fn prop_compute_identity_yields_all_modified_zero(result in arb_analysis_result()) {
1231 let n = result.functions.len();
1232 let delta = compute(result.clone(), result);
1233 prop_assert_eq!(delta.changes.len(), n);
1234 for change in &delta.changes {
1235 let is_modified = matches!(change, FunctionChange::Modified { .. });
1236 prop_assert!(is_modified);
1237 prop_assert_eq!(change.score_delta(), Some(0.0));
1238 }
1239 let summary = DeltaSummary::compute(&delta.changes);
1240 prop_assert_eq!(summary.added, 0);
1241 prop_assert_eq!(summary.removed, 0);
1242 prop_assert_eq!(summary.modified, n as u32);
1243 prop_assert_eq!(summary.regressions, 0);
1244 prop_assert_eq!(summary.improvements, 0);
1245 prop_assert_eq!(summary.new_violations, 0);
1246 prop_assert!(summary.passed);
1247 }
1248
1249 #[test]
1252 fn prop_changes_count_bounded(
1253 baseline in arb_analysis_result(),
1254 current in arb_analysis_result(),
1255 ) {
1256 let baseline_len = baseline.functions.len();
1257 let current_len = current.functions.len();
1258 let delta = compute(baseline, current);
1259 let n = delta.changes.len();
1260 prop_assert!(n <= baseline_len + current_len);
1265 let modified_count = delta
1267 .changes
1268 .iter()
1269 .filter(|c| matches!(c, FunctionChange::Modified { .. }))
1270 .count();
1271 prop_assert!(modified_count <= baseline_len);
1272 prop_assert!(modified_count <= current_len);
1273 }
1274
1275 #[test]
1278 fn prop_new_violations_well_bounded(
1279 baseline in arb_analysis_result(),
1280 current in arb_analysis_result(),
1281 ) {
1282 let delta = compute(baseline, current);
1283 let summary = DeltaSummary::compute(&delta.changes);
1284 prop_assert!(summary.new_violations <= summary.added + summary.modified);
1285 prop_assert_eq!(summary.passed, summary.new_violations == 0);
1286 }
1287
1288 #[test]
1292 fn prop_view_shown_subset_of_changes(
1293 baseline in arb_analysis_result(),
1294 current in arb_analysis_result(),
1295 ) {
1296 let delta = compute(baseline, current);
1297 let view = apply(&delta, DeltaViewSpec::default());
1298 prop_assert!(view.shown.len() <= view.eligible_count);
1299 prop_assert!(view.eligible_count <= delta.changes.len());
1300 prop_assert_eq!(view.shown.len() == view.eligible_count, !view.truncated);
1301 }
1302
1303 #[test]
1306 fn prop_apply_does_not_mutate_summary(
1307 baseline in arb_analysis_result(),
1308 current in arb_analysis_result(),
1309 ) {
1310 let delta = compute(baseline, current);
1311 let original_passed = delta.summary.passed;
1312 let view = apply(&delta, DeltaViewSpec::default());
1313 prop_assert_eq!(view.full.summary.passed, original_passed);
1314 }
1315 }
1316}