Skip to main content

crap_core/core/
mod.rs

1//! Wiring layer — composes adapters through ports, exposes `analyze()` API.
2//!
3//! Language-agnostic orchestrator: discovers source files, dispatches per-
4//! function complexity extraction through `ComplexityPort`, parses coverage
5//! through `CoveragePort<Diagnostic = P>`, matches functions, scores, and
6//! produces results. Per-adapter language coupling stays in the caller's
7//! crate (`crap4rs::adapters::complexity`, `crap4rs::adapters::coverage`).
8//!
9//! Relocated from `crap4rs::core` in S4 (#136). `analyze` is generic over
10//! `<P: ParseDiagnostic>` so the same orchestrator powers both the LCOV
11//! adapter (crap4rs) and future siblings (crap4ts's Istanbul adapter), per
12//! ADR D9 (mixed-dispatch-strategy).
13
14pub mod walker;
15
16use std::path::{Path, PathBuf};
17
18use anyhow::{Context, Result, bail};
19
20use self::walker::discover_rust_files;
21
22use crate::adapters::diff::GitDiffAdapter;
23use crate::domain::crap::compute_crap;
24use crate::domain::diagnostic::compute_diagnostic;
25use crate::domain::matching::{match_functions, overlaps_any};
26use crate::domain::summary::compute_summary;
27use crate::domain::threshold::ThresholdConfig;
28use crate::domain::types::{
29    AnalysisDiagnostics, AnalysisResult, ComplexityMetric, CoverageMetric, FileChangeKind,
30    FunctionComplexity, FunctionVerdict, LineCoverage, ScoredFunction,
31};
32use crate::ports::{ComplexityPort, CoveragePort, DiffPort, ParseDiagnostic, ParseOutput};
33
34/// Options for running a CRAP analysis.
35#[derive(Debug)]
36pub struct AnalyzeOptions {
37    /// Root directory of Rust source files to analyze.
38    pub src: PathBuf,
39    /// Path to the LCOV coverage file.
40    pub coverage: PathBuf,
41    /// Threshold configuration with optional per-path overrides.
42    pub threshold_config: ThresholdConfig,
43    /// Which complexity metric to use.
44    pub metric: ComplexityMetric,
45    /// Which coverage metric to use for analysis.
46    pub coverage_metric: CoverageMetric,
47    /// Glob patterns to exclude from file discovery.
48    pub exclude: Vec<String>,
49    /// Whether to respect .gitignore files during file discovery.
50    pub respect_gitignore: bool,
51    /// Git ref to diff against. When set, only changed functions are analyzed.
52    pub diff_ref: Option<String>,
53    /// When `true`, populate `FunctionVerdict.diagnostic` for every
54    /// over-threshold verdict via `domain::diagnostic::compute_diagnostic`.
55    /// CLI sets this for `--format advice` and `--format sarif`. The
56    /// computation is pure-domain and bounded — runs only on exceeding
57    /// verdicts, so the cost scales with violations, not total functions.
58    pub compute_diagnostics: bool,
59}
60
61/// Full output from an analysis run, including both the scored results
62/// and process diagnostics (surfaced by `--verbose`).
63///
64/// Generic over `P: ParseDiagnostic` so `AnalysisDiagnostics<P>` can carry
65/// the adapter-specific parse-diagnostic shape (LCOV's
66/// `LcovParseDiagnostic`, future Istanbul adapter's diagnostic, etc.) per
67/// ADR D9.
68#[derive(Debug)]
69pub struct AnalysisOutput<P: ParseDiagnostic> {
70    pub result: AnalysisResult,
71    pub diagnostics: AnalysisDiagnostics<P>,
72}
73
74struct DiscoveredSources {
75    source_files: Vec<PathBuf>,
76    files_found: usize,
77}
78
79struct ExtractedComplexities {
80    all_complexities: Vec<FunctionComplexity>,
81    files_unparseable: usize,
82}
83
84impl Default for AnalyzeOptions {
85    fn default() -> Self {
86        Self {
87            src: PathBuf::from("src"),
88            coverage: PathBuf::from("lcov.info"),
89            threshold_config: ThresholdConfig::default(),
90            metric: ComplexityMetric::default(),
91            coverage_metric: CoverageMetric::default(),
92            exclude: Vec::new(),
93            respect_gitignore: true,
94            diff_ref: None,
95            compute_diagnostics: false,
96        }
97    }
98}
99
100/// Run CRAP analysis: discover files, extract complexity, parse coverage,
101/// match, score, and produce results with diagnostics.
102///
103/// Thin facade over a private `AnalysisContext`. Phase methods on the
104/// context compose the pipeline; diff-mode short-circuits are surfaced
105/// via `Option<AnalysisOutput<P>>` so the top-level orchestration stays
106/// flat.
107///
108/// Mixed dispatch (ADR D9): port adapters arrive as trait objects so the
109/// monomorphization tax stays bounded; `<P>` is the single generic
110/// parameter that flows from `CoveragePort::Diagnostic` through to
111/// `AnalysisDiagnostics<P>` in the result.
112pub fn analyze<P: ParseDiagnostic>(
113    options: &AnalyzeOptions,
114    complexity: &dyn ComplexityPort,
115    coverage: &dyn CoveragePort<Diagnostic = P>,
116) -> Result<AnalysisOutput<P>> {
117    AnalysisContext::new(options, complexity, coverage).run()
118}
119
120/// Mutable per-run context: bundles `&AnalyzeOptions` with the canonicalized
121/// source root and the injected port adapters so phase methods don't repeat
122/// that setup.
123///
124/// The pipeline phases are `&self`-methods that read options and produce
125/// fresh values; nothing is mutated on the context itself. Diff-mode
126/// short-circuits return `Option<AnalysisOutput<P>>` — `Some(early)`
127/// signals "no work left, return this," `None` signals "continue."
128struct AnalysisContext<'a, P: ParseDiagnostic> {
129    options: &'a AnalyzeOptions,
130    complexity: &'a dyn ComplexityPort,
131    coverage: &'a dyn CoveragePort<Diagnostic = P>,
132    src_canonical: PathBuf,
133}
134
135impl<'a, P: ParseDiagnostic> AnalysisContext<'a, P> {
136    fn new(
137        options: &'a AnalyzeOptions,
138        complexity: &'a dyn ComplexityPort,
139        coverage: &'a dyn CoveragePort<Diagnostic = P>,
140    ) -> Self {
141        Self {
142            options,
143            complexity,
144            coverage,
145            src_canonical: canonicalize_src(&options.src),
146        }
147    }
148
149    fn run(&self) -> Result<AnalysisOutput<P>> {
150        let mut discovered = self.discover_sources()?;
151        let diff_data = self.load_diff_data(&discovered.source_files)?;
152
153        if let Some(early) = self.short_circuit_on_files(&mut discovered, diff_data.as_ref()) {
154            return Ok(early);
155        }
156
157        let mut parse_output = self.parse_coverage()?;
158        let ExtractedComplexities {
159            mut all_complexities,
160            files_unparseable,
161        } = self.extract_complexities(&discovered.source_files)?;
162        let functions_extracted = all_complexities.len();
163
164        if let Some(early) = self.short_circuit_on_complexities(
165            &mut all_complexities,
166            diff_data.as_ref(),
167            &mut parse_output,
168            &discovered,
169            files_unparseable,
170            functions_extracted,
171        ) {
172            return Ok(early);
173        }
174
175        ensure_functions_extracted(&all_complexities, &self.options.src)?;
176
177        let matched = match_functions(
178            &all_complexities,
179            &parse_output.coverage,
180            parse_output.branches.as_ref(),
181        );
182
183        let functions_no_coverage = matched
184            .iter()
185            .filter(|(comp, _)| !parse_output.coverage.contains_key(&comp.identity.file_path))
186            .count();
187        let functions_matched = matched.len() - functions_no_coverage;
188
189        let resolver = ThresholdResolver::new(&self.options.threshold_config)?;
190        let mut result = score_and_summarize(&matched, &resolver)?;
191        populate_diagnostics(
192            &mut result.functions,
193            &parse_output.coverage,
194            self.options.compute_diagnostics,
195        );
196
197        let (files_analyzed, files_zero_coverage) = compute_file_coverage_stats(&result);
198
199        let diagnostics = AnalysisDiagnostics {
200            parse_diagnostics: parse_output.diagnostics,
201            files_found: discovered.files_found,
202            files_unparseable,
203            functions_extracted,
204            functions_matched,
205            functions_no_coverage,
206            files_analyzed,
207            files_zero_coverage,
208        };
209
210        debug_assert_eq!(
211            diagnostics.functions_matched + diagnostics.functions_no_coverage,
212            result.functions.len(),
213            "diagnostics counts must partition scored functions"
214        );
215
216        Ok(AnalysisOutput {
217            result,
218            diagnostics,
219        })
220    }
221
222    fn discover_sources(&self) -> Result<DiscoveredSources> {
223        let source_files = discover_rust_files(
224            &self.options.src,
225            &self.options.exclude,
226            self.options.respect_gitignore,
227        )?;
228        let files_found = source_files.len();
229        ensure_source_files_found(&source_files, &self.options.src)?;
230
231        Ok(DiscoveredSources {
232            source_files,
233            files_found,
234        })
235    }
236
237    fn load_diff_data(
238        &self,
239        source_files: &[PathBuf],
240    ) -> Result<Option<std::collections::HashMap<String, FileChangeKind>>> {
241        self.options
242            .diff_ref
243            .as_deref()
244            .map(|diff_ref| {
245                compute_diff_regions(
246                    diff_ref,
247                    &self.src_canonical,
248                    &self.options.src,
249                    source_files,
250                )
251            })
252            .transpose()
253    }
254
255    fn parse_coverage(&self) -> Result<ParseOutput<P>> {
256        let coverage_data = std::fs::read_to_string(&self.options.coverage).with_context(|| {
257            format!(
258                "failed to read coverage file: {}",
259                self.options.coverage.display()
260            )
261        })?;
262        self.coverage
263            .parse(&coverage_data)
264            .map_err(|e| anyhow::anyhow!("{e}"))
265    }
266
267    fn extract_complexities(&self, source_files: &[PathBuf]) -> Result<ExtractedComplexities> {
268        let mut all_complexities = Vec::new();
269        let mut files_unparseable = 0usize;
270
271        for file_path in source_files {
272            let source = std::fs::read_to_string(file_path)
273                .with_context(|| format!("failed to read source file: {}", file_path.display()))?;
274            let relative = src_relative_path(file_path, &self.options.src);
275
276            match self
277                .complexity
278                .extract(&source, &relative, self.options.metric)
279            {
280                Ok(fns) => all_complexities.extend(fns),
281                Err(e) => {
282                    files_unparseable += 1;
283                    eprintln!("warning: skipping {relative}: {e}");
284                }
285            }
286        }
287
288        Ok(ExtractedComplexities {
289            all_complexities,
290            files_unparseable,
291        })
292    }
293
294    /// Apply diff-mode file filtering and signal early-exit if nothing remains.
295    /// Returns `None` when there is no diff ref OR the filter still leaves
296    /// files to analyze; returns `Some(empty_output)` when the diff cleared
297    /// every source file.
298    fn short_circuit_on_files(
299        &self,
300        discovered: &mut DiscoveredSources,
301        diff_data: Option<&std::collections::HashMap<String, FileChangeKind>>,
302    ) -> Option<AnalysisOutput<P>> {
303        let diff_result = diff_data?;
304        retain_changed_source_files(&mut discovered.source_files, &self.options.src, diff_result);
305        if !discovered.source_files.is_empty() {
306            return None;
307        }
308        Some(empty_output_with_diagnostics(diagnostics_for_empty_result(
309            vec![],
310            discovered.files_found,
311            0,
312            0,
313        )))
314    }
315
316    /// Apply diff-mode function filtering and signal early-exit if nothing
317    /// remains. Same `Option<AnalysisOutput<P>>` signal as
318    /// [`Self::short_circuit_on_files`]; takes `parse_output` mutably so the
319    /// empty-result diagnostics can move out of `parse_output.diagnostics`
320    /// without cloning when this branch fires.
321    fn short_circuit_on_complexities(
322        &self,
323        complexities: &mut Vec<FunctionComplexity>,
324        diff_data: Option<&std::collections::HashMap<String, FileChangeKind>>,
325        parse_output: &mut ParseOutput<P>,
326        discovered: &DiscoveredSources,
327        files_unparseable: usize,
328        functions_extracted: usize,
329    ) -> Option<AnalysisOutput<P>> {
330        let diff_result = diff_data?;
331        retain_changed_functions(complexities, diff_result);
332        if !complexities.is_empty() {
333            return None;
334        }
335        Some(empty_output_with_diagnostics(diagnostics_for_empty_result(
336            std::mem::take(&mut parse_output.diagnostics),
337            discovered.files_found,
338            files_unparseable,
339            functions_extracted,
340        )))
341    }
342}
343
344fn canonicalize_src(src: &Path) -> PathBuf {
345    src.canonicalize().unwrap_or_else(|_| src.to_path_buf())
346}
347
348fn ensure_source_files_found(source_files: &[PathBuf], src: &Path) -> Result<()> {
349    if source_files.is_empty() {
350        bail!(
351            "no Rust source files found in {}\n  \
352             hint: check that --src points to a directory containing .rs files",
353            src.display()
354        );
355    }
356    Ok(())
357}
358
359fn retain_changed_source_files(
360    source_files: &mut Vec<PathBuf>,
361    src_root: &Path,
362    diff_result: &std::collections::HashMap<String, FileChangeKind>,
363) {
364    source_files.retain(|path| {
365        let rel = src_relative_path(path, src_root);
366        diff_result.contains_key(&rel)
367    });
368}
369
370fn retain_changed_functions(
371    all_complexities: &mut Vec<FunctionComplexity>,
372    diff_result: &std::collections::HashMap<String, FileChangeKind>,
373) {
374    all_complexities.retain(|comp| match diff_result.get(&comp.identity.file_path) {
375        Some(FileChangeKind::NewFile) => true,
376        Some(FileChangeKind::Modified(ranges)) => overlaps_any(&comp.identity.span, ranges),
377        None => false,
378    });
379}
380
381fn ensure_functions_extracted(all_complexities: &[FunctionComplexity], src: &Path) -> Result<()> {
382    if all_complexities.is_empty() {
383        bail!(
384            "no functions extracted from source files in {}\n  \
385             hint: check that source files contain valid Rust function definitions",
386            src.display()
387        );
388    }
389    Ok(())
390}
391
392fn diagnostics_for_empty_result<P: ParseDiagnostic>(
393    parse_diagnostics: Vec<P>,
394    files_found: usize,
395    files_unparseable: usize,
396    functions_extracted: usize,
397) -> AnalysisDiagnostics<P> {
398    AnalysisDiagnostics {
399        parse_diagnostics,
400        files_found,
401        files_unparseable,
402        functions_extracted,
403        functions_matched: 0,
404        functions_no_coverage: 0,
405        files_analyzed: 0,
406        files_zero_coverage: 0,
407    }
408}
409
410fn empty_output_with_diagnostics<P: ParseDiagnostic>(
411    diagnostics: AnalysisDiagnostics<P>,
412) -> AnalysisOutput<P> {
413    AnalysisOutput {
414        result: empty_passing_result(),
415        diagnostics,
416    }
417}
418
419fn compute_file_coverage_stats(result: &AnalysisResult) -> (usize, usize) {
420    let mut file_is_zero_coverage: std::collections::HashMap<&str, bool> =
421        std::collections::HashMap::new();
422    for verdict in &result.functions {
423        let entry = file_is_zero_coverage
424            .entry(verdict.scored.identity.file_path.as_str())
425            .or_insert(true);
426        if verdict.scored.coverage_percent > 0.0 {
427            *entry = false;
428        }
429    }
430
431    let total = file_is_zero_coverage.len();
432    let zero = file_is_zero_coverage
433        .values()
434        .filter(|&&is_zero| is_zero)
435        .count();
436    (total, zero)
437}
438
439/// Resolves per-function thresholds using glob-based overrides.
440///
441/// Compiled once per analysis run. Patterns are evaluated in declaration
442/// order with last-match-wins semantics.
443struct ThresholdResolver {
444    global: f64,
445    overrides: Vec<(globset::GlobMatcher, f64)>,
446}
447
448impl ThresholdResolver {
449    fn new(config: &ThresholdConfig) -> Result<Self> {
450        let overrides = config
451            .overrides
452            .iter()
453            .map(|o| {
454                let glob = globset::Glob::new(&o.pattern)
455                    .with_context(|| format!("invalid glob pattern: {}", o.pattern))?
456                    .compile_matcher();
457                Ok((glob, o.threshold))
458            })
459            .collect::<Result<Vec<_>>>()?;
460
461        Ok(Self {
462            global: config.global,
463            overrides,
464        })
465    }
466
467    /// Resolve the threshold for a given file path.
468    /// Last matching override wins; falls back to global.
469    fn resolve(&self, file_path: &str) -> f64 {
470        let mut threshold = self.global;
471        for (matcher, override_threshold) in &self.overrides {
472            if matcher.is_match(file_path) {
473                threshold = *override_threshold;
474            }
475        }
476        threshold
477    }
478}
479
480/// Score matched functions against the threshold config and produce the final result.
481fn score_and_summarize(
482    matched: &[(
483        crate::domain::types::FunctionComplexity,
484        crate::domain::types::FunctionCoverage,
485    )],
486    resolver: &ThresholdResolver,
487) -> Result<AnalysisResult> {
488    let mut verdicts = Vec::with_capacity(matched.len());
489    for (comp, cov) in matched {
490        let crap = compute_crap(comp.complexity, cov.line_coverage.percent)
491            .map_err(|e| anyhow::anyhow!("{e}"))?;
492
493        let threshold = resolver.resolve(&comp.identity.file_path);
494
495        verdicts.push(FunctionVerdict {
496            scored: ScoredFunction {
497                identity: comp.identity.clone(),
498                complexity: comp.complexity,
499                complexity_metric: comp.metric,
500                coverage_percent: cov.line_coverage.percent,
501                crap,
502                contributors: comp.contributors.clone(),
503            },
504            threshold,
505            exceeds: crap.value > threshold,
506            diagnostic: None,
507        });
508    }
509
510    let summary = compute_summary(&verdicts);
511    let passed = verdicts.iter().all(|v| !v.exceeds);
512
513    Ok(AnalysisResult {
514        functions: verdicts,
515        summary,
516        passed,
517    })
518}
519
520/// Populate `verdict.diagnostic` for every over-threshold verdict.
521/// `compute_diagnostic` returns `None` for passing verdicts, so the
522/// existing `skip_serializing_if = "Option::is_none"` keeps the JSON
523/// envelope clean for them.
524fn populate_diagnostics(
525    verdicts: &mut [FunctionVerdict],
526    coverage: &std::collections::HashMap<String, Vec<LineCoverage>>,
527    enabled: bool,
528) {
529    if !enabled {
530        return;
531    }
532    for verdict in verdicts.iter_mut() {
533        if !verdict.exceeds {
534            continue;
535        }
536        let lines = coverage
537            .get(&verdict.scored.identity.file_path)
538            .map(Vec::as_slice)
539            .unwrap_or(&[]);
540        verdict.diagnostic = compute_diagnostic(verdict, lines).map(Box::new);
541    }
542}
543
544/// Strip `src_root` prefix from a path, returning a forward-slash-normalised string.
545/// Panics if the path is not under `src_root` (a bug in the file walker).
546fn src_relative_path(path: &Path, src_root: &Path) -> String {
547    path.strip_prefix(src_root)
548        .expect("discovered file should be under the source root")
549        .to_string_lossy()
550        .replace('\\', "/")
551}
552
553/// Compute diff regions for the given ref, reconciling repo-root-relative paths
554/// from `git diff` with src-relative paths used by the complexity adapter.
555fn compute_diff_regions(
556    diff_ref: &str,
557    src_canonical: &Path,
558    src_original: &Path,
559    source_files: &[PathBuf],
560) -> Result<std::collections::HashMap<String, FileChangeKind>> {
561    let diff_adapter = GitDiffAdapter::new();
562
563    // Git diff outputs paths relative to repo root, but the complexity
564    // adapter uses paths relative to options.src. We bridge via src_prefix.
565    let repo_root = git_toplevel(src_canonical)?;
566    let src_prefix = src_canonical
567        .strip_prefix(&repo_root)
568        .with_context(|| {
569            format!(
570                "--src directory {} is not inside the git repository at {}\n  \
571                 hint: --diff requires --src to be within the git work tree",
572                src_canonical.display(),
573                repo_root.display(),
574            )
575        })?
576        .to_string_lossy()
577        .replace('\\', "/");
578
579    // Paths passed to `git diff -- <paths>` must be repo-relative.
580    // source_files use the original (possibly symlinked) src path.
581    let repo_relative_paths: Vec<String> = source_files
582        .iter()
583        .map(|p| {
584            let src_rel = src_relative_path(p, src_original);
585            if src_prefix.is_empty() {
586                src_rel
587            } else {
588                format!("{src_prefix}/{src_rel}")
589            }
590        })
591        .collect();
592
593    let raw_diff = diff_adapter
594        .changed_regions(diff_ref, &repo_root, &repo_relative_paths)
595        .map_err(|e| anyhow::anyhow!(e))?;
596
597    // Strip src_prefix from diff result keys to get src-relative paths
598    let prefix_with_slash = if src_prefix.is_empty() {
599        String::new()
600    } else {
601        format!("{src_prefix}/")
602    };
603    Ok(raw_diff
604        .into_iter()
605        .filter_map(|(path, kind)| {
606            if prefix_with_slash.is_empty() {
607                Some((path, kind))
608            } else {
609                path.strip_prefix(&prefix_with_slash)
610                    .map(|stripped| (stripped.to_string(), kind))
611            }
612        })
613        .collect())
614}
615
616fn git_toplevel(from_dir: &Path) -> Result<PathBuf> {
617    let output = std::process::Command::new("git")
618        .current_dir(from_dir)
619        .args(["rev-parse", "--show-toplevel"])
620        .output()
621        .context("failed to run git rev-parse")?;
622
623    if output.status.success() {
624        let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
625        PathBuf::from(&toplevel)
626            .canonicalize()
627            .with_context(|| format!("failed to canonicalize git toplevel: {toplevel}"))
628    } else {
629        let stderr = String::from_utf8_lossy(&output.stderr);
630        bail!("not inside a git work tree: {}", stderr.trim());
631    }
632}
633
634/// Produce an empty, passing result for when diff filtering yields no functions.
635fn empty_passing_result() -> AnalysisResult {
636    AnalysisResult {
637        functions: vec![],
638        summary: compute_summary(&[]),
639        passed: true,
640    }
641}
642
643// `discover_rust_files` relocated to
644// `crap_core::core::walker::discover_rust_files` in S3 (#135). The
645// import at the top of this file binds the name back into local scope
646// so the call sites in `discover_sources` keep compiling.
647
648#[cfg(test)]
649mod tests {
650    //! Language-agnostic core tests. The end-to-end pipeline tests that
651    //! exercise `analyze` over real LCOV + Rust source live in
652    //! `crap4rs/tests/analyze_pipeline_tests.rs` (S4 #136 — the analyze
653    //! signature gained `&dyn ComplexityPort` + `&dyn CoveragePort` ports
654    //! and the test fixtures need the LCOV/syn adapters that live in the
655    //! crap4rs adapter crate).
656    use super::*;
657    use crate::domain::threshold::ThresholdOverride;
658
659    #[test]
660    fn score_and_summarize_threads_contributors() {
661        use crate::domain::types::{
662            ComplexityContributor, ContributorKind, FunctionCoverage, SourceSpan,
663        };
664
665        let contributor = ComplexityContributor {
666            kind: ContributorKind::IfBranch,
667            line: 5,
668            column: Some(4),
669            increment: 1,
670            end_line: 5,
671            nesting_depth: 0,
672        };
673        let comp = crate::domain::types::FunctionComplexity {
674            identity: crate::domain::types::FunctionIdentity {
675                file_path: "src/lib.rs".to_string(),
676                qualified_name: "test_fn".to_string(),
677                span: SourceSpan {
678                    start_line: 1,
679                    end_line: 10,
680                    start_column: 0,
681                    end_column: 0,
682                },
683            },
684            complexity: 2,
685            metric: crate::domain::types::ComplexityMetric::Cognitive,
686            contributors: vec![contributor.clone()],
687        };
688        let cov = FunctionCoverage {
689            file_path: "src/lib.rs".to_string(),
690            span: SourceSpan {
691                start_line: 1,
692                end_line: 10,
693                start_column: 0,
694                end_column: 0,
695            },
696            line_coverage: crate::domain::types::CoverageRatio {
697                covered: 10,
698                total: 10,
699                percent: 100.0,
700            },
701            branch_coverage: None,
702        };
703
704        let config = crate::domain::threshold::ThresholdConfig::default();
705        let resolver = ThresholdResolver::new(&config).unwrap();
706        let result = score_and_summarize(&[(comp, cov)], &resolver).unwrap();
707
708        assert_eq!(result.functions.len(), 1);
709        let verdict = &result.functions[0];
710        assert_eq!(verdict.scored.contributors.len(), 1);
711        assert_eq!(verdict.scored.contributors[0], contributor);
712    }
713
714    // ── ThresholdResolver tests ───────────────────────────────────────
715
716    #[test]
717    fn resolver_global_only() {
718        let config = ThresholdConfig {
719            global: 10.0,
720            overrides: vec![],
721        };
722        let resolver = ThresholdResolver::new(&config).unwrap();
723        assert_eq!(resolver.resolve("domain/crap.rs"), 10.0);
724        assert_eq!(resolver.resolve("adapters/coverage/mod.rs"), 10.0);
725    }
726
727    #[test]
728    fn resolver_override_matches() {
729        let config = ThresholdConfig {
730            global: 8.0,
731            overrides: vec![ThresholdOverride {
732                pattern: "domain/**".to_string(),
733                threshold: 5.0,
734            }],
735        };
736        let resolver = ThresholdResolver::new(&config).unwrap();
737        assert_eq!(resolver.resolve("domain/crap.rs"), 5.0);
738        assert_eq!(resolver.resolve("adapters/coverage/mod.rs"), 8.0);
739    }
740
741    #[test]
742    fn resolver_last_match_wins() {
743        let config = ThresholdConfig {
744            global: 8.0,
745            overrides: vec![
746                ThresholdOverride {
747                    pattern: "**/*.rs".to_string(),
748                    threshold: 10.0,
749                },
750                ThresholdOverride {
751                    pattern: "domain/**".to_string(),
752                    threshold: 5.0,
753                },
754            ],
755        };
756        let resolver = ThresholdResolver::new(&config).unwrap();
757        // domain/crap.rs matches both — last wins (5.0)
758        assert_eq!(resolver.resolve("domain/crap.rs"), 5.0);
759        // adapters/mod.rs matches only first (10.0)
760        assert_eq!(resolver.resolve("adapters/mod.rs"), 10.0);
761    }
762
763    #[test]
764    fn resolver_no_match_falls_back_to_global() {
765        let config = ThresholdConfig {
766            global: 8.0,
767            overrides: vec![ThresholdOverride {
768                pattern: "domain/**".to_string(),
769                threshold: 5.0,
770            }],
771        };
772        let resolver = ThresholdResolver::new(&config).unwrap();
773        assert_eq!(resolver.resolve("cli/mod.rs"), 8.0);
774    }
775
776    #[test]
777    fn resolver_invalid_glob_rejected() {
778        let config = ThresholdConfig {
779            global: 8.0,
780            overrides: vec![ThresholdOverride {
781                pattern: "[invalid".to_string(),
782                threshold: 5.0,
783            }],
784        };
785        assert!(ThresholdResolver::new(&config).is_err());
786    }
787
788    #[test]
789    fn empty_passing_result_has_zero_functions() {
790        let result = empty_passing_result();
791        assert!(result.functions.is_empty());
792        assert!(result.passed);
793        assert_eq!(result.summary.total_functions, 0);
794    }
795}