1pub 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#[derive(Debug)]
36pub struct AnalyzeOptions {
37 pub src: PathBuf,
39 pub coverage: PathBuf,
41 pub threshold_config: ThresholdConfig,
43 pub metric: ComplexityMetric,
45 pub coverage_metric: CoverageMetric,
47 pub exclude: Vec<String>,
49 pub respect_gitignore: bool,
51 pub diff_ref: Option<String>,
53 pub compute_diagnostics: bool,
59}
60
61#[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
100pub 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
120struct 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 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 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
439struct 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 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
480fn 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
520fn 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
544fn 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
553fn 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 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 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 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
634fn empty_passing_result() -> AnalysisResult {
636 AnalysisResult {
637 functions: vec![],
638 summary: compute_summary(&[]),
639 passed: true,
640 }
641}
642
643#[cfg(test)]
649mod tests {
650 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 #[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 assert_eq!(resolver.resolve("domain/crap.rs"), 5.0);
759 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}