Skip to main content

crap_core/domain/
summary.rs

1use serde::Serialize;
2
3use super::delta::{DeltaSummary, FunctionChange};
4use super::types::{
5    AnalysisResult, AnalysisSummary, CrapScore, FunctionIdentity, FunctionVerdict,
6    RiskDistribution, RiskLevel,
7};
8
9/// Compute an `AnalysisSummary` from any iterable of `&FunctionVerdict`.
10///
11/// Accepting an `IntoIterator` instead of a slice lets callers pass either
12/// `&[FunctionVerdict]` (the typical core path) or `&[&FunctionVerdict]`
13/// (the `view::apply` path, where `shown` is already a vec of borrows).
14/// This avoids the deep clone that would otherwise be required to
15/// materialise an owned slice on every `view::apply` invocation.
16pub 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// ── Per-file aggregation (`--group-by file`) ────────────────────────
143
144/// Per-file aggregate over a `FunctionVerdict` partition.
145///
146/// The partition key is `FunctionIdentity::file_path`. Aggregates are
147/// pure: no `syn`, no LCOV, no `PathBuf` — just integers, floats, and
148/// the already-domain identity string. Ships unchanged into `crap-core`.
149#[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    /// Mean of finite `coverage_percent` values across the file's
160    /// functions. NaN inputs are excluded from both numerator and
161    /// denominator; if no finite values exist, this is `0.0`.
162    /// Drives file-level `--sort-by coverage`.
163    pub average_coverage: f64,
164    /// Highest `complexity` value across the file's functions.
165    /// `0` for an empty bucket (unreachable in practice — `function_count`
166    /// is always >= 1 for a returned summary). Drives file-level
167    /// `--sort-by complexity`.
168    pub max_complexity: u32,
169}
170
171/// Group `verdicts` by `file_path` and compute per-file aggregates.
172///
173/// Order of the returned vec is undefined — callers (e.g. the View)
174/// must apply their own sort. Empty input returns an empty vec.
175///
176/// Mirrors `compute_summary`'s `IntoIterator<Item = &'a FunctionVerdict>`
177/// signature so it composes with `view::apply` without forcing a clone.
178pub fn compute_file_summaries<'a, I>(verdicts: I) -> Vec<FileSummary>
179where
180    I: IntoIterator<Item = &'a FunctionVerdict>,
181{
182    // Stable insertion order keeps the output deterministic for fixture
183    // tests; callers that want a specific order sort downstream.
184    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
207/// Per-verdict accumulator for `file_summary_for`.
208///
209/// Holds running totals so the per-verdict update is a single fold step
210/// without keeping the cognitive complexity of `file_summary_for` itself
211/// above the project's strict-CRAP gate (CC ≤ 15).
212struct 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        // Strip NaN from the median set so per-file median is deterministic
247        // (CodeRabbit observation on summary.rs:256 — `partial_cmp.unwrap_or(Equal)`
248        // can otherwise leave NaN at the middle index). NaN scores still
249        // contribute to `sum_crap` to mirror `compute_summary`'s aggregate
250        // behavior; in practice the CRAP formula is always finite, so this
251        // only matters as a defensive contract.
252        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        // equivalent-mutant: `>` vs `>=` here is observationally identical —
270        // re-assigning `max_complexity` to the same value is a no-op, so
271        // cargo-mutants reports a survivor that does not represent a real bug.
272        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
287/// Strict-greater: first verdict at the max wins (matches `compute_summary`'s
288/// "first wins" semantics on ties).
289fn 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
323/// Sort-stable median for a non-empty score vector. NaN handling mirrors
324/// `compute_summary`: `partial_cmp` falls back to `Equal` so NaN does
325/// not panic. Empty input returns `0.0` to match `compute_summary`.
326fn 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// ── CrapDeltaRowData (scorecard-row producer) ────────────
340
341/// Per-row verdict for the scorecard-row contract (Green/Yellow/Red).
342/// Producer-mints-status — the adapter sets this from its own
343/// analysis; aggregators consume it as-is.
344#[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    /// Canonical wire string — equal to the serde JSON representation
354    /// (PascalCase, sans quotes). See
355    /// `crate::domain::types::ContributorKind::as_wire_str` for the
356    /// rationale; equality with serde is pinned in
357    /// `tests::wire_str_matches_serde`.
358    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/// Language-agnostic record carrying the structured signal a producer
368/// projects into a `Row::CrapDelta` JSON object. Lives in
369/// `domain/summary.rs` (pure-domain — no `syn`, no LCOV). The
370/// reporter at `adapters/reporters/scorecard_row.rs` wires this
371/// record into the locked wire shape (see
372/// `crates/crap4rs/tests/fixtures/scorecard/schema.json`).
373#[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
382/// Project the scorecard-row signal from a current analysis +
383/// optional baseline + delta. Status is producer-side (Model P):
384///
385/// - **Red** when `delta_summary.new_violations > 0`.
386/// - **Yellow** when no new violations but `regressions > 0`.
387/// - **Green** otherwise.
388///
389/// When `baseline` and `delta` are `None` (single-snapshot mode), status
390/// is Green if no functions exceed the threshold, Red otherwise; the
391/// row treats baseline_count = 0 so `delta_count == current_count`.
392pub 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        // Median of [2, 4, 6, 8] = (4 + 6) / 2 = 5.0
651        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),         // Low (<=8)
658            make_verdict("a.rs", "acceptable", 10.0, 30.0), // Acceptable (<=15)
659            make_verdict("a.rs", "moderate", 20.0, 30.0),   // Moderate (<=25)
660            make_verdict("a.rs", "high", 50.0, 30.0),       // High (>25)
661        ];
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),     // below
700            make_verdict("a.rs", "bad", 15.0, 10.0),   // exceeds
701            make_verdict("a.rs", "worse", 50.0, 10.0), // exceeds
702        ];
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        // P8 — empty robustness.
772        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        // P1: sum(file.function_count) == verdicts.len()
794        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        // P3: len == distinct file paths
808        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        // P2 (per-file).
824        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        // P2 (global): sum(file.exceeding_count) == verdicts.iter().filter(|v| v.exceeds).count().
840        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        // P4: per-file max_crap matches manual max.
855        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        // P5: per-file average_crap matches manual mean.
871        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        // (4 + 6 + 14) / 3 = 8.0
881        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); // (2+8)/2
899    }
900
901    #[test]
902    fn distribution_per_file() {
903        let verdicts = vec![
904            vrd("a.rs", "low", 2.0, 25.0),         // Low (<=8)
905            vrd("a.rs", "acceptable", 10.0, 25.0), // Acceptable (<=15)
906            vrd("a.rs", "moderate", 20.0, 25.0),   // Moderate (<=25)
907            vrd("a.rs", "high", 50.0, 25.0),       // High (>25)
908        ];
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        // P8 (NaN). Coverage NaN does not affect file aggregation since
921        // file aggregates are over CRAP not coverage; assert no panic.
922        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        // Defensive: all-NaN coverage produces 0 finite count; the guard
952        // in `file_summary_for` must keep `average_coverage` at 0.0 (not NaN).
953        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        // `file_summary_for` is private and `compute_file_summaries` never
960        // calls it with empty input — but the defensive guards in the body
961        // (`function_count > 0`, `finite_coverage_count > 0`) lock the
962        // contract: an empty-bucket FileSummary has zeroed averages, never
963        // NaN. This test pins both guards by direct invocation.
964        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        // Two finite (50.0, 100.0) and one NaN → mean = 75.0
975        let v_finite_a = vrd("a.rs", "f1", 1.0, 25.0); // coverage 100.0 (default in vrd)
976        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        // Two functions in the same file at the same CRAP — first wins.
999        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        /// P1: partition completeness.
1021        #[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        /// P2: aggregation faithfulness for `exceeding_count`.
1031        #[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        /// P3: one row per distinct file path.
1042        #[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            // and the file_paths in summaries match the distinct set
1053            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        /// P4: per-file max_crap is the max within the file.
1059        #[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        /// P5: per-file average_crap is the mean within the file.
1082        #[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        /// P8: never panics on empty / arbitrary inputs.
1101        #[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    // ── No-baseline (single-snapshot) mode ───────────────────────────
1169
1170    #[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(&current, 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(&current, 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        // Sorted by CRAP descending: worse (30.0) before bad (23.4).
1195        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    // ── Baseline + delta — Red on new violations ─────────────────────
1203
1204    #[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, &current);
1212        let row = project_crap_delta_row(
1213            &current,
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, &current);
1231        let row = project_crap_delta_row(
1232            &current,
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    // ── Baseline + delta — Yellow on regression-only ────────────────
1247
1248    #[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, &current);
1253        let row = project_crap_delta_row(
1254            &current,
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    // ── Baseline + delta — Green ─────────────────────────────────────
1266
1267    #[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, &current);
1272        let row = project_crap_delta_row(
1273            &current,
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, &current);
1289        let row = project_crap_delta_row(
1290            &current,
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, &current);
1309        let row = project_crap_delta_row(
1310            &current,
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    // ── Failure detail ordering ──────────────────────────────────────
1322
1323    #[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, &current);
1332        let row = project_crap_delta_row(
1333            &current,
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    // ── Threshold pass-through ───────────────────────────────────────
1347
1348    #[test]
1349    fn threshold_value_passed_through() {
1350        let current = make_result(vec![]);
1351        let row = project_crap_delta_row(&current, None, None, 25);
1352        assert_eq!(row.threshold, 25);
1353    }
1354}