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