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