1use crate::finding::Finding;
2use crate::rules::{convert_severity, RuleContext, RuleRegistry, SuppressionSet};
3use crate::syntax::{parse_rust_files, ParseFailure, ParsedFile, SyntaxReport};
4use serde::Serialize;
5use std::path::{Path, PathBuf};
6use walkdir::WalkDir;
7
8#[derive(Debug, Clone, Default)]
9pub struct ScanOptions {
10 pub include_tests: bool,
11 pub rule_filter: Option<String>,
12}
13
14#[derive(Debug, Clone, Default, Serialize)]
15pub struct ScanResult {
16 pub findings: Vec<Finding>,
17 pub files_scanned: usize,
18 pub files_parsed: usize,
19 pub parse_failures: Vec<ParseFailure>,
20}
21
22#[derive(Default)]
23pub struct Scanner {
24 rules: RuleRegistry,
25}
26
27impl Scanner {
28 pub fn new() -> Self {
29 Self {
30 rules: RuleRegistry::baseline(),
31 }
32 }
33
34 pub fn scan_path(&self, path: &str, options: &ScanOptions) -> ScanResult {
35 let file_paths: Vec<PathBuf> = discover_rust_files(path, options).collect();
36 let files_scanned = file_paths.len();
37 let syntax_report = parse_rust_files(file_paths);
38 self.scan_report(files_scanned, syntax_report, options)
39 }
40
41 pub fn scan_report(
42 &self,
43 files_scanned: usize,
44 report: SyntaxReport,
45 options: &ScanOptions,
46 ) -> ScanResult {
47 let files_parsed = report.files.len();
48 let findings = self.run_rules(&report.files, options);
49 let parse_failures = report.parse_failures;
50
51 ScanResult {
52 findings,
53 files_scanned,
54 files_parsed,
55 parse_failures,
56 }
57 }
58
59 fn run_rules(&self, files: &[ParsedFile], options: &ScanOptions) -> Vec<Finding> {
60 let ctx = RuleContext { files };
61 let suppressions: Vec<(String, SuppressionSet)> = files
62 .iter()
63 .map(|file| (file.path.display().to_string(), SuppressionSet::from_source(&file.source)))
64 .collect();
65
66 let mut findings = Vec::new();
67 for file in files {
68 for rule in self.rules.matching_rules(options.rule_filter.as_deref()) {
69 for matched in rule.match_file(file, &ctx) {
70 let finding = Finding {
71 rule_id: matched.rule_id.to_string(),
72 severity: convert_severity(matched.severity),
73 message: matched.message,
74 location: matched.location,
75 help: matched.help,
76 suppressed: false,
77 };
78
79 if is_suppressed(&finding, &suppressions) {
80 continue;
81 }
82
83 findings.push(finding);
84 }
85 }
86 }
87
88 findings
89 }
90}
91
92fn is_suppressed(finding: &Finding, suppressions: &[(String, SuppressionSet)]) -> bool {
93 suppressions
94 .iter()
95 .find(|(path, _)| path == &finding.location.path)
96 .is_some_and(|(_, set)| set.is_suppressed(finding))
97}
98
99fn discover_rust_files<'a>(
100 path: &'a str,
101 options: &'a ScanOptions,
102) -> impl Iterator<Item = PathBuf> + 'a {
103 WalkDir::new(path)
104 .into_iter()
105 .filter_map(Result::ok)
106 .filter(|entry| entry.file_type().is_file())
107 .filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("rs"))
108 .filter(|entry| !is_excluded_path(entry.path()))
109 .filter(move |entry| options.include_tests || !is_test_path(entry.path()))
110 .map(|entry| entry.into_path())
111}
112
113fn is_excluded_path(path: &Path) -> bool {
114 path.components().any(|component| {
115 let part = component.as_os_str().to_string_lossy();
116 matches!(part.as_ref(), "target" | ".git")
117 })
118}
119
120fn is_test_path(path: &Path) -> bool {
121 path.components().any(|component| {
122 let part = component.as_os_str().to_string_lossy();
123 matches!(part.as_ref(), "tests" | "test" | "fixtures")
124 })
125}