1pub 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#[derive(Debug)]
36pub struct AnalyzeOptions {
37 pub src: PathBuf,
39 pub coverage: PathBuf,
42 pub threshold_config: ThresholdConfig,
44 pub metric: ComplexityMetric,
46 pub coverage_metric: CoverageMetric,
48 pub exclude: Vec<String>,
50 pub respect_gitignore: bool,
52 pub diff_ref: Option<String>,
54 pub extensions: Vec<String>,
64 pub compute_diagnostics: bool,
70}
71
72#[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 extensions: Vec::new(),
112 compute_diagnostics: false,
113 }
114 }
115}
116
117pub 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
137struct 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 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 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 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 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
394pub(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 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
530struct 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 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
571fn 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 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
619fn 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
643fn 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
652fn 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 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 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 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
733fn empty_passing_result() -> AnalysisResult {
735 AnalysisResult {
736 functions: vec![],
737 summary: compute_summary(&[]),
738 passed: true,
739 }
740}
741
742#[cfg(test)]
748mod tests {
749 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 #[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 assert_eq!(resolver.resolve("domain/crap.rs"), 5.0);
857 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}