1use std::collections::HashMap;
2use std::fs::{self, read_to_string};
3use std::path::{Path, PathBuf};
4
5use ast_grep_config::{
6 CombinedScan, GlobalRules, RuleCollection, RuleConfig, RuleConfigError, SerializableRuleConfig,
7};
8use ast_grep_core::tree_sitter::LanguageExt;
9use ast_grep_core::Language as AstLanguage;
10use ast_grep_core::{AstGrep, Doc};
11use ast_grep_language::SupportLang;
12use globset::{Error as GlobError, Glob, GlobSet, GlobSetBuilder};
13use ignore::WalkBuilder;
14use rayon::prelude::*;
15use serde::Serialize;
16use serde_yaml::with::singleton_map_recursive;
17use serde_yaml::{to_value, Value};
18use strum::IntoEnumIterator;
19use thiserror::Error;
20
21use crate::cache::{file_content_hash, rules_hash, CacheEntry, CacheStore};
22use crate::config::Config;
23use crate::cross_file::{self, CrossFileKind, DeclFilter, FileSymbols, SymbolIndex};
24use crate::disable::filter_disabled;
25use crate::finding::{CacheStats, Finding, ScanResult, ScanStats};
26use crate::metric::{self, Metric};
27use crate::rule::{Language, Rule, RuleId, Severity};
28use crate::test_filter::{is_test_path, CfgTestRanges};
29
30#[derive(Debug, Error)]
31pub enum ScanError {
32 #[error("failed to compile rule '{id}': {source}")]
33 RuleCompile {
34 id: RuleId,
35 #[source]
36 source: RuleConfigError,
37 },
38
39 #[error("invalid glob pattern: {0}")]
40 Glob(#[from] GlobError),
41
42 #[error("rule '{id}': metric rule is missing a threshold")]
43 InvalidMetricRule { id: RuleId },
44}
45
46#[derive(Serialize)]
49pub struct AstGrepRule<'a> {
50 pub id: &'a str,
51 pub language: SupportLang,
52 pub severity: &'a Severity,
53 pub message: &'a str,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub note: Option<&'a str>,
56 pub rule: &'a Value,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub files: Option<&'a [String]>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub ignores: Option<&'a [String]>,
61}
62
63struct CompiledRules<'a> {
66 collection: RuleCollection<SupportLang>,
67 by_id: HashMap<(&'a str, Language), &'a Rule>,
68 metrics: Vec<MetricRule<'a>>,
69 cross_file: Vec<CrossFileRule<'a>>,
70}
71
72struct CrossFileRule<'a> {
75 rule: &'a Rule,
76 kind: CrossFileKind,
77 decl_filter: DeclFilter,
78}
79
80struct MetricRule<'a> {
84 rule: &'a Rule,
85 metric: Metric,
86 threshold: f64,
87 files: Option<GlobSet>,
88 ignores: Option<GlobSet>,
89}
90
91impl MetricRule<'_> {
92 fn applies_to(&self, path: &Path, lang: &Language) -> bool {
93 self.rule.language == *lang
94 && self.files.as_ref().is_none_or(|g| g.is_match(path))
95 && !self.ignores.as_ref().is_some_and(|g| g.is_match(path))
96 }
97}
98
99fn extension_to_lang(path: &Path) -> Option<SupportLang> {
100 SupportLang::from_path(path)
101 .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
102}
103
104fn support_lang_to_language(lang: SupportLang) -> Language {
105 match lang {
106 SupportLang::Rust => Language::Rust,
107 _ => Language::TypeScript,
108 }
109}
110
111pub(crate) fn compile_ast_grep_rule(
112 rule: &AstGrepRule,
113) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
114 let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
115 .and_then(singleton_map_recursive::deserialize)
116 .map_err(RuleConfigError::from)?;
117 RuleConfig::try_from(inner, &GlobalRules::default())
118}
119
120fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
121 let ast_grep_rule = AstGrepRule {
122 id: rule.id.as_str(),
123 language: lang,
124 severity: &rule.severity,
125 message: &rule.message,
126 note: rule.note.as_deref(),
127 rule: &rule.rule,
128 files: rule.files.as_deref(),
129 ignores: rule.ignores.as_deref(),
130 };
131 compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
132 id: rule.id.clone(),
133 source,
134 })
135}
136
137fn build_metric_rule(rule: &Rule) -> Result<MetricRule<'_>, ScanError> {
139 let Some((metric, threshold)) = rule.metric_spec() else {
140 return Err(ScanError::InvalidMetricRule {
141 id: rule.id.clone(),
142 });
143 };
144 Ok(MetricRule {
145 rule,
146 metric,
147 threshold,
148 files: rule.files.as_deref().map(build_glob_set).transpose()?,
149 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
150 })
151}
152
153fn build_cross_file_rule(rule: &Rule, kind: CrossFileKind) -> Result<CrossFileRule<'_>, ScanError> {
157 Ok(CrossFileRule {
158 rule,
159 kind,
160 decl_filter: DeclFilter {
161 files: rule.files.as_deref().map(build_glob_set).transpose()?,
162 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
163 skip_test_code: rule.skip_test_code,
164 },
165 })
166}
167
168fn compile_rules(rules: &[Rule]) -> Result<CompiledRules<'_>, ScanError> {
169 let (cross_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
172 rules.iter().partition(|r| r.is_cross_file());
173 let (metric_rules, ast_rules): (Vec<&Rule>, Vec<&Rule>) =
174 rest.into_iter().partition(|r| r.is_metric());
175
176 let configs = ast_rules
177 .iter()
178 .copied()
179 .flat_map(|rule| {
180 rule.language
181 .ast_grep_langs()
182 .iter()
183 .map(move |lang| compile_rule(rule, *lang))
184 })
185 .collect::<Result<Vec<_>, _>>()?;
186 Ok(CompiledRules {
187 collection: RuleCollection::try_new(configs)?,
188 by_id: ast_rules
189 .iter()
190 .copied()
191 .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
192 .collect::<HashMap<_, _>>(),
193 metrics: metric_rules
194 .into_iter()
195 .map(build_metric_rule)
196 .collect::<Result<Vec<_>, _>>()?,
197 cross_file: cross_rules
198 .into_iter()
199 .filter_map(|rule| rule.cross_file_kind().map(|kind| (rule, kind)))
200 .map(|(rule, kind)| build_cross_file_rule(rule, kind))
201 .collect::<Result<Vec<_>, _>>()?,
202 })
203}
204
205fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
206 let mut builder = GlobSetBuilder::new();
207 for pattern in patterns {
208 builder.add(Glob::new(pattern)?);
209 }
210 builder.build()
211}
212
213fn metric_findings<D: Doc>(
219 metrics: &[&MetricRule],
220 path: &Path,
221 root: &AstGrep<D>,
222 source: &str,
223 lang: &Language,
224) -> Vec<Finding> {
225 metrics
226 .iter()
227 .filter(|m| !(m.rule.skip_test_code && is_test_path(path)))
228 .filter_map(|m| {
229 let value = metric::compute(m.metric, lang, root, source);
230 if !metric::exceeds(value, m.threshold) {
231 return None;
232 }
233 let rendered = m.metric.format_value(value);
234 Some(Finding {
235 rule_id: m.rule.id.clone(),
236 severity: m.rule.severity.clone(),
237 category: m.rule.category.clone().unwrap_or_default(),
238 message: m.rule.message.replace("$value", &rendered),
239 note: m.rule.note.clone(),
240 fix: m.rule.fix.clone(),
241 file: path.to_path_buf(),
242 line: 1,
243 column: 1,
244 end_line: 1,
245 end_column: 1,
246 matched_text: m.metric.describe(value),
247 confidence: None,
248 escalated: false,
249 })
250 })
251 .collect()
252}
253
254#[derive(Default)]
258struct FileScan {
259 findings: Vec<Finding>,
260 symbols: FileSymbols,
261}
262
263fn scan_file(
264 path: &Path,
265 lang: SupportLang,
266 rules: &CompiledRules,
267 collect_symbols: bool,
268) -> FileScan {
269 let rule_lang = support_lang_to_language(lang);
270 let applicable = rules.collection.get_rule_from_lang(path, lang);
271 let metrics: Vec<&MetricRule> = rules
272 .metrics
273 .iter()
274 .filter(|m| m.applies_to(path, &rule_lang))
275 .collect();
276 let want_symbols = collect_symbols && lang == SupportLang::Rust;
279 if applicable.is_empty() && metrics.is_empty() && !want_symbols {
280 return FileScan::default();
281 }
282 let Ok(source) = read_to_string(path) else {
283 return FileScan::default();
284 };
285
286 let root = lang.ast_grep(&source);
287 let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&root));
289
290 let mut findings: Vec<Finding> = if applicable.is_empty() {
291 Vec::new()
292 } else {
293 let combined = CombinedScan::new(applicable);
294 let result = combined.scan(&root, false);
295
296 let cfg_test = cfg_test.as_ref();
297 let is_test_file = is_test_path(path);
298
299 result
300 .matches
301 .into_iter()
302 .filter_map(|(config, matches)| {
303 let rule = rules.by_id.get(&(config.id.as_str(), rule_lang.clone()))?;
304 Some((*rule, matches))
305 })
306 .flat_map(|(rule, matches)| {
307 let skip_test_code = rule.skip_test_code;
308 matches
309 .into_iter()
310 .map(move |node_match| {
311 let start = node_match.start_pos();
312 let end = node_match.end_pos();
313 Finding {
314 rule_id: rule.id.clone(),
315 severity: rule.severity.clone(),
316 category: rule.category.clone().unwrap_or_default(),
317 message: rule.message.clone(),
318 note: rule.note.clone(),
319 fix: rule.fix.clone(),
320 file: path.to_path_buf(),
321 line: start.line() + 1,
322 column: start.byte_point().1 + 1,
323 end_line: end.line() + 1,
324 end_column: end.byte_point().1 + 1,
325 matched_text: node_match.text().to_string(),
326 confidence: None,
327 escalated: false,
328 }
329 })
330 .filter(move |f| {
331 if !skip_test_code {
332 return true;
333 }
334 let in_test_code =
335 is_test_file || cfg_test.is_some_and(|r| r.contains_line(f.line));
336 !in_test_code
337 })
338 })
339 .collect()
340 };
341
342 findings.extend(metric_findings(&metrics, path, &root, &source, &rule_lang));
343
344 let symbols = match (want_symbols, cfg_test.as_ref()) {
347 (true, Some(ranges)) => cross_file::extract_rust_symbols(&root, ranges),
348 _ => FileSymbols::default(),
349 };
350
351 FileScan {
352 findings: filter_disabled(findings, &source),
353 symbols,
354 }
355}
356
357fn cross_file_findings(
361 rules: &[CrossFileRule<'_>],
362 contributions: &[(PathBuf, FileSymbols)],
363) -> Vec<Finding> {
364 let index = SymbolIndex::build(contributions.iter().map(|(p, s)| (p.as_path(), s)));
365 let mut by_file: HashMap<PathBuf, Vec<Finding>> = HashMap::new();
366 for rule in rules {
367 let found = cross_file::evaluate(rule.rule, rule.kind, &index, &rule.decl_filter);
368 for finding in found {
369 by_file
370 .entry(finding.file.clone())
371 .or_default()
372 .push(finding);
373 }
374 }
375 by_file
376 .into_iter()
377 .flat_map(|(path, findings)| match read_to_string(&path) {
378 Ok(source) => filter_disabled(findings, &source),
381 Err(_) => findings,
382 })
383 .collect()
384}
385
386fn append_cross_file(
392 findings: &mut Vec<Finding>,
393 compiled: &CompiledRules,
394 run_cross_file: bool,
395 contributions: &[(PathBuf, FileSymbols)],
396) {
397 if run_cross_file && !compiled.cross_file.is_empty() {
398 findings.extend(cross_file_findings(&compiled.cross_file, contributions));
399 }
400}
401
402fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
404 paths
405 .iter()
406 .flat_map(|path| WalkBuilder::new(path).build().flatten())
407 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
408 .map(|entry| entry.into_path())
409 .filter(|path| !ignores.is_match(path))
410 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
411 .collect()
412}
413
414fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
420 files
421 .iter()
422 .filter(|path| path.is_file())
423 .filter(|path| !ignores.is_match(path))
424 .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
425 .collect()
426}
427
428pub fn count_severities(findings: &[Finding]) -> (usize, usize) {
430 let mut errors = 0;
431 let mut warnings = 0;
432 for finding in findings {
433 match finding.severity {
434 Severity::Error => errors += 1,
435 Severity::Warning => warnings += 1,
436 }
437 }
438 (errors, warnings)
439}
440
441fn normalize_findings(findings: &mut Vec<Finding>) {
443 findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
444 findings.dedup_by(|a, b| {
445 a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
446 });
447}
448
449fn scan_collected(
454 files: Vec<(PathBuf, SupportLang)>,
455 compiled: &CompiledRules,
456 run_cross_file: bool,
457) -> ScanResult {
458 let collect_symbols = !compiled.cross_file.is_empty();
459 let scans: Vec<(PathBuf, FileScan)> = files
460 .par_iter()
461 .map(|(path, lang)| {
462 let scan = scan_file(path, *lang, compiled, collect_symbols);
463 (path.clone(), scan)
464 })
465 .collect();
466
467 let mut findings: Vec<Finding> = Vec::new();
468 let mut contributions: Vec<(PathBuf, FileSymbols)> = Vec::new();
469 for (path, scan) in scans {
470 findings.extend(scan.findings);
471 if collect_symbols {
472 contributions.push((path, scan.symbols));
473 }
474 }
475 append_cross_file(&mut findings, compiled, run_cross_file, &contributions);
476 normalize_findings(&mut findings);
477
478 let (errors, warnings) = count_severities(&findings);
479
480 ScanResult {
481 findings,
482 stats: ScanStats {
483 errors,
484 warnings,
485 total: errors + warnings,
486 files_scanned: files.len(),
487 baseline_filtered: 0,
488 diff_base: None,
489 files_changed: None,
490 },
491 cache_stats: None,
492 }
493}
494
495fn scan_collected_cached(
502 files: Vec<(PathBuf, SupportLang)>,
503 compiled: &CompiledRules,
504 rules: &[Rule],
505 cache_dir: &Path,
506 prune: bool,
507 run_cross_file: bool,
508) -> ScanResult {
509 let collect_symbols = !compiled.cross_file.is_empty();
510 let current_rules_hash = rules_hash(rules);
511 let store = CacheStore::with_dir(cache_dir.to_path_buf()).scoped_to_rules(¤t_rules_hash);
512 let rules_changed = store
513 .check_rules_changed(¤t_rules_hash)
514 .unwrap_or(true);
515
516 let file_contents: Vec<(PathBuf, SupportLang, Vec<u8>, String)> = files
517 .into_iter()
518 .filter_map(|(path, lang)| {
519 let content = fs::read(&path).ok()?;
520 let hash = file_content_hash(&content);
521 Some((path, lang, content, hash))
522 })
523 .collect();
524
525 let mut cached_count = 0usize;
526 let mut changed_count = 0usize;
527 let mut all_findings: Vec<Finding> = Vec::new();
528 let mut contributions: Vec<(PathBuf, FileSymbols)> = Vec::new();
529
530 struct FileWork {
531 path: PathBuf,
532 lang: SupportLang,
533 hash: String,
534 }
535
536 let mut to_scan: Vec<FileWork> = Vec::new();
537
538 for (path, lang, _content, hash) in file_contents.iter() {
539 if !rules_changed {
540 if let Some(entry) = store.get(hash) {
541 cached_count += 1;
542 all_findings.extend(entry.findings);
543 if collect_symbols {
544 contributions.push((path.clone(), entry.symbols));
545 }
546 continue;
547 }
548 }
549 changed_count += 1;
550 to_scan.push(FileWork {
551 path: path.clone(),
552 lang: *lang,
553 hash: hash.clone(),
554 });
555 }
556
557 let scanned: Vec<(PathBuf, String, FileScan)> = to_scan
558 .par_iter()
559 .map(|work| {
560 let scan = scan_file(&work.path, work.lang, compiled, collect_symbols);
561 (work.path.clone(), work.hash.clone(), scan)
562 })
563 .collect();
564
565 for (path, hash, scan) in scanned {
566 let entry = CacheEntry {
567 findings: scan.findings,
568 symbols: scan.symbols,
569 };
570 store.put(&hash, &entry).ok();
571 all_findings.extend(entry.findings);
572 if collect_symbols {
573 contributions.push((path, entry.symbols));
574 }
575 }
576
577 append_cross_file(&mut all_findings, compiled, run_cross_file, &contributions);
580
581 if prune {
582 let current_hashes: Vec<String> =
583 file_contents.iter().map(|(_, _, _, h)| h.clone()).collect();
584 store.cleanup(¤t_hashes).ok();
585 }
586
587 normalize_findings(&mut all_findings);
588
589 let (errors, warnings) = count_severities(&all_findings);
590
591 let files_scanned = cached_count + changed_count;
592
593 ScanResult {
594 findings: all_findings,
595 stats: ScanStats {
596 errors,
597 warnings,
598 total: errors + warnings,
599 files_scanned,
600 baseline_filtered: 0,
601 diff_base: None,
602 files_changed: None,
603 },
604 cache_stats: Some(CacheStats {
605 cached: cached_count,
606 changed: changed_count,
607 }),
608 }
609}
610
611pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
613 let compiled = compile_rules(rules)?;
614 let ignores = build_glob_set(&config.scan.ignores)?;
615 Ok(scan_collected(walk_files(paths, &ignores), &compiled, true))
616}
617
618pub fn scan_cached(
627 paths: &[PathBuf],
628 rules: &[Rule],
629 config: &Config,
630 cache_dir: &Path,
631) -> Result<ScanResult, ScanError> {
632 let compiled = compile_rules(rules)?;
633 let ignores = build_glob_set(&config.scan.ignores)?;
634 Ok(scan_collected_cached(
635 walk_files(paths, &ignores),
636 &compiled,
637 rules,
638 cache_dir,
639 true,
640 true,
641 ))
642}
643
644pub fn scan_files(
654 files: &[PathBuf],
655 rules: &[Rule],
656 config: &Config,
657) -> Result<ScanResult, ScanError> {
658 let compiled = compile_rules(rules)?;
659 let ignores = build_glob_set(&config.scan.ignores)?;
660 Ok(scan_collected(
661 explicit_files(files, &ignores),
662 &compiled,
663 false,
664 ))
665}
666
667pub fn scan_files_cached(
675 files: &[PathBuf],
676 rules: &[Rule],
677 config: &Config,
678 cache_dir: &Path,
679) -> Result<ScanResult, ScanError> {
680 let compiled = compile_rules(rules)?;
681 let ignores = build_glob_set(&config.scan.ignores)?;
682 Ok(scan_collected_cached(
683 explicit_files(files, &ignores),
684 &compiled,
685 rules,
686 cache_dir,
687 false,
688 false,
689 ))
690}
691
692#[cfg(test)]
693mod tests {
694 use std::fs::{create_dir, write};
695
696 use tempfile::tempdir;
697
698 use super::*;
699 use crate::rule::{load_builtin_rules, parse_rule, RuleId};
700
701 fn unwrap_rule() -> Rule {
702 parse_rule(
703 r#"
704id: test-unwrap
705language: rust
706severity: error
707category: correctness
708message: ".unwrap() forbidden"
709rule:
710 kind: call_expression
711 regex: '\.unwrap\(\)\s*$'
712"#,
713 )
714 .unwrap()
715 }
716
717 fn scan_dir(dir: &Path, rules: &[Rule]) -> ScanResult {
718 scan(&[dir.to_path_buf()], rules, &Config::default()).unwrap()
719 }
720
721 #[test]
722 fn scan_finds_unwrap_violation() {
723 let dir = tempdir().unwrap();
724 write(
725 dir.path().join("main.rs"),
726 "fn main() {\n foo().unwrap();\n}\n",
727 )
728 .unwrap();
729 let result = scan_dir(dir.path(), &[unwrap_rule()]);
730 assert_eq!(result.findings.len(), 1);
731 let f = &result.findings[0];
732 assert_eq!(f.rule_id, RuleId::from("test-unwrap"));
733 assert_eq!(f.severity, Severity::Error);
734 assert_eq!(f.line, 2);
735 assert!(f.matched_text.contains("unwrap()"));
736 }
737
738 #[test]
739 fn scan_clean_file_no_findings() {
740 let dir = tempdir().unwrap();
741 write(dir.path().join("main.rs"), "fn main() { let x = foo(); }\n").unwrap();
742 let result = scan_dir(dir.path(), &[unwrap_rule()]);
743 assert_eq!(result.findings.len(), 0);
744 assert_eq!(result.stats.files_scanned, 1);
745 }
746
747 #[test]
748 fn scan_unsupported_language_ignored() {
749 let dir = tempdir().unwrap();
750 write(dir.path().join("script.py"), "x.unwrap()\n").unwrap();
751 write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
752 let result = scan_dir(dir.path(), &[unwrap_rule()]);
753 assert_eq!(result.findings.len(), 0);
754 assert_eq!(result.stats.files_scanned, 1);
755 }
756
757 #[test]
758 fn scan_explicit_file_path() {
759 let dir = tempdir().unwrap();
760 let file = dir.path().join("main.rs");
761 write(&file, "fn main() { foo().unwrap(); }\n").unwrap();
762 let result = scan(&[file], &[unwrap_rule()], &Config::default()).unwrap();
763 assert_eq!(result.findings.len(), 1);
764 assert_eq!(result.stats.files_scanned, 1);
765 }
766
767 #[test]
768 fn rule_ignores_glob_respected() {
769 let dir = tempdir().unwrap();
770 let tests_dir = dir.path().join("tests");
771 create_dir(&tests_dir).unwrap();
772 write(
773 tests_dir.join("test_main.rs"),
774 "fn t() { foo().unwrap(); }\n",
775 )
776 .unwrap();
777 write(
778 dir.path().join("main.rs"),
779 "fn main() { foo().unwrap(); }\n",
780 )
781 .unwrap();
782 let rule = parse_rule(
783 r#"
784id: ign
785language: rust
786severity: error
787category: correctness
788message: x
789rule:
790 kind: call_expression
791 regex: '\.unwrap\(\)\s*$'
792ignores:
793 - "**/tests/**"
794"#,
795 )
796 .unwrap();
797 let result = scan_dir(dir.path(), &[rule]);
798 assert_eq!(result.findings.len(), 1);
799 assert!(result.findings[0]
800 .file
801 .to_string_lossy()
802 .contains("main.rs"));
803 }
804
805 #[test]
806 fn config_ignores_respected() {
807 let dir = tempdir().unwrap();
808 let gen = dir.path().join("generated");
809 create_dir(&gen).unwrap();
810 write(gen.join("out.rs"), "fn g() { foo().unwrap(); }\n").unwrap();
811 write(
812 dir.path().join("main.rs"),
813 "fn main() { foo().unwrap(); }\n",
814 )
815 .unwrap();
816 let mut cfg = Config::default();
817 cfg.scan.ignores = vec!["**/generated/**".to_string()];
818 let result = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap();
819 assert_eq!(result.stats.files_scanned, 1);
820 assert_eq!(result.findings.len(), 1);
821 }
822
823 #[test]
824 fn config_invalid_glob_is_an_error() {
825 let dir = tempdir().unwrap();
826 let mut cfg = Config::default();
827 cfg.scan.ignores = vec!["[".to_string()];
828 let err = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap_err();
829 assert!(matches!(err, ScanError::Glob(_)), "{err:?}");
830 }
831
832 #[test]
833 fn rule_without_kind_fails_to_compile() {
834 let dir = tempdir().unwrap();
835 let rule = parse_rule(
836 r#"
837id: no-kind
838language: rust
839severity: error
840category: correctness
841message: x
842rule:
843 regex: 'unwrap'
844"#,
845 )
846 .unwrap();
847 let err = scan(&[dir.path().to_path_buf()], &[rule], &Config::default()).unwrap_err();
848 assert!(
849 matches!(&err, ScanError::RuleCompile { id, .. } if *id == RuleId::from("no-kind")),
850 "{err:?}"
851 );
852 }
853
854 #[test]
855 fn stats_counting_accurate() {
856 let dir = tempdir().unwrap();
857 write(
858 dir.path().join("a.rs"),
859 "fn f() { foo().unwrap(); bar().unwrap(); }\n",
860 )
861 .unwrap();
862 let result = scan_dir(dir.path(), &[unwrap_rule()]);
863 assert_eq!(result.stats.errors, 2);
864 assert_eq!(result.stats.warnings, 0);
865 assert_eq!(result.stats.total, 2);
866 assert_eq!(result.stats.files_scanned, 1);
867 }
868
869 #[test]
870 fn scan_empty_file() {
871 let dir = tempdir().unwrap();
872 write(dir.path().join("empty.rs"), "").unwrap();
873 let result = scan_dir(dir.path(), &[unwrap_rule()]);
874 assert_eq!(result.findings.len(), 0);
875 assert_eq!(result.stats.files_scanned, 1);
876 }
877
878 #[test]
879 fn scan_complex_rule() {
880 let rule = parse_rule(
881 r#"
882id: dbg
883language: rust
884severity: error
885category: security
886message: debug on secret
887rule:
888 kind: attribute_item
889 all:
890 - regex: 'derive\([^)]*Debug'
891 - precedes:
892 kind: struct_item
893 has:
894 kind: field_identifier
895 regex: '^(api_key|password|secret)$'
896 stopBy: end
897"#,
898 )
899 .unwrap();
900 let dir = tempdir().unwrap();
901 write(
902 dir.path().join("t.rs"),
903 "#[derive(Debug)]\nstruct Config {\n api_key: String,\n}\n",
904 )
905 .unwrap();
906 let result = scan_dir(dir.path(), &[rule]);
907 assert_eq!(result.findings.len(), 1);
908 assert_eq!(result.findings[0].rule_id, RuleId::from("dbg"));
909 }
910
911 #[test]
912 fn typescript_rule_applies_to_tsx_files() {
913 let rule = parse_rule(
914 r#"
915id: ts-any
916language: typescript
917severity: warning
918category: slop
919message: no any
920rule:
921 kind: predefined_type
922 regex: '^any$'
923"#,
924 )
925 .unwrap();
926 let dir = tempdir().unwrap();
927 write(dir.path().join("a.ts"), "let x: any = 1;\n").unwrap();
928 write(dir.path().join("b.tsx"), "let y: any = 2;\n").unwrap();
929 let result = scan_dir(dir.path(), &[rule]);
930 assert_eq!(result.stats.files_scanned, 2);
931 assert_eq!(result.stats.warnings, 2);
932 }
933
934 #[test]
935 fn scan_with_disable_comment_filters_findings() {
936 let dir = tempdir().unwrap();
937 write(
938 dir.path().join("main.rs"),
939 "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n bar().unwrap();\n}\n",
940 )
941 .unwrap();
942 let result = scan_dir(dir.path(), &[unwrap_rule()]);
943 assert_eq!(
944 result.findings.len(),
945 1,
946 "only the non-disabled unwrap should remain"
947 );
948 assert_eq!(result.findings[0].line, 4);
949 }
950
951 #[test]
952 fn scan_with_disable_rule_id_filters_only_that_rule() {
953 let dir = tempdir().unwrap();
954 write(
955 dir.path().join("main.rs"),
956 "fn main() {\n // slopguard-disable-next-line test-unwrap\n foo().unwrap();\n}\n",
957 )
958 .unwrap();
959 let result = scan_dir(dir.path(), &[unwrap_rule()]);
960 assert_eq!(result.findings.len(), 0);
961 }
962
963 fn unwrap_rule_skipping_test_code() -> Rule {
964 parse_rule(
965 r#"
966id: test-unwrap
967language: rust
968severity: error
969category: correctness
970message: ".unwrap() forbidden"
971rule:
972 kind: call_expression
973 regex: '\.unwrap\(\)\s*$'
974skip_test_code: true
975"#,
976 )
977 .unwrap()
978 }
979
980 #[test]
981 fn cfg_test_block_excluded_by_post_filter() {
982 let dir = tempdir().unwrap();
983 write(
984 dir.path().join("main.rs"),
985 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
986 )
987 .unwrap();
988 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
989 assert_eq!(
990 result.findings.len(),
991 1,
992 "should only find the prod unwrap, not the one inside #[cfg(test)]"
993 );
994 assert_eq!(result.findings[0].line, 2);
995 }
996
997 #[test]
998 fn cfg_test_kept_without_skip_test_code() {
999 let dir = tempdir().unwrap();
1000 write(
1001 dir.path().join("main.rs"),
1002 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
1003 )
1004 .unwrap();
1005 let result = scan_dir(dir.path(), &[unwrap_rule()]);
1006 assert_eq!(
1007 result.findings.len(),
1008 2,
1009 "rule without skip_test_code should find both"
1010 );
1011 }
1012
1013 #[test]
1014 fn integration_test_file_excluded_by_skip_test_code() {
1015 let dir = tempdir().unwrap();
1016 let tests_dir = dir.path().join("tests");
1017 std::fs::create_dir(&tests_dir).unwrap();
1018 write(
1019 tests_dir.join("it.rs"),
1020 "fn check() {\n foo().unwrap();\n}\n",
1021 )
1022 .unwrap();
1023 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
1024 assert_eq!(
1025 result.findings.len(),
1026 0,
1027 "unwrap inside a tests/ integration file should be skipped"
1028 );
1029 }
1030
1031 #[test]
1032 fn integration_test_file_kept_without_skip_test_code() {
1033 let dir = tempdir().unwrap();
1034 let tests_dir = dir.path().join("tests");
1035 std::fs::create_dir(&tests_dir).unwrap();
1036 write(
1037 tests_dir.join("it.rs"),
1038 "fn check() {\n foo().unwrap();\n}\n",
1039 )
1040 .unwrap();
1041 let result = scan_dir(dir.path(), &[unwrap_rule()]);
1042 assert_eq!(
1043 result.findings.len(),
1044 1,
1045 "rule without skip_test_code should still flag tests/ files"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_crate_directory_excluded_by_skip_test_code() {
1051 let dir = tempdir().unwrap();
1052 let test_crate = dir
1053 .path()
1054 .join("crates")
1055 .join("integrations_tests")
1056 .join("src");
1057 std::fs::create_dir_all(&test_crate).unwrap();
1058 write(
1059 test_crate.join("api.rs"),
1060 "fn check() {\n foo().unwrap();\n}\n",
1061 )
1062 .unwrap();
1063 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
1064 assert_eq!(
1065 result.findings.len(),
1066 0,
1067 "unwrap inside a *_tests crate should be skipped"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_crate_directory_kept_without_skip_test_code() {
1073 let dir = tempdir().unwrap();
1074 let test_crate = dir
1075 .path()
1076 .join("crates")
1077 .join("integrations_tests")
1078 .join("src");
1079 std::fs::create_dir_all(&test_crate).unwrap();
1080 write(
1081 test_crate.join("api.rs"),
1082 "fn check() {\n foo().unwrap();\n}\n",
1083 )
1084 .unwrap();
1085 let result = scan_dir(dir.path(), &[unwrap_rule()]);
1086 assert_eq!(
1087 result.findings.len(),
1088 1,
1089 "rule without skip_test_code should still flag *_tests crate files"
1090 );
1091 }
1092
1093 #[test]
1094 fn all_builtin_rules_compile() {
1095 let rules = load_builtin_rules().unwrap();
1096 assert!(
1097 rules.len() >= 55,
1098 "expected at least 55 rules, got {}",
1099 rules.len()
1100 );
1101 let compiled = compile_rules(&rules).expect("all builtin rules should compile");
1102 let ast_rule_count = rules
1103 .iter()
1104 .filter(|r| !r.is_metric() && !r.is_cross_file())
1105 .count();
1106 assert_eq!(compiled.by_id.len(), ast_rule_count);
1107 assert!(
1108 !compiled.cross_file.is_empty(),
1109 "builtin rules should include cross-file rules"
1110 );
1111 assert!(
1112 !compiled.metrics.is_empty(),
1113 "builtin rules should include metric rules"
1114 );
1115 }
1116
1117 #[test]
1118 fn scan_files_only_scans_given_files() {
1119 let dir = tempdir().unwrap();
1120 let a = dir.path().join("a.rs");
1121 let b = dir.path().join("b.rs");
1122 write(&a, "fn a() {\n foo().unwrap();\n}\n").unwrap();
1123 write(&b, "fn b() {\n bar().unwrap();\n}\n").unwrap();
1124
1125 let result = scan_files(&[a], &[unwrap_rule()], &Config::default()).unwrap();
1126 assert_eq!(result.findings.len(), 1);
1127 assert_eq!(result.stats.files_scanned, 1);
1128 assert!(result.findings[0].file.to_string_lossy().contains("a.rs"));
1129 }
1130
1131 #[test]
1132 fn scan_files_skips_unsupported_language() {
1133 let dir = tempdir().unwrap();
1134 let py = dir.path().join("script.py");
1135 write(&py, "x.unwrap()\n").unwrap();
1136
1137 let result = scan_files(&[py], &[unwrap_rule()], &Config::default()).unwrap();
1138 assert_eq!(result.findings.len(), 0);
1139 assert_eq!(result.stats.files_scanned, 0);
1140 }
1141
1142 #[test]
1143 fn scan_files_skips_missing_file() {
1144 let dir = tempdir().unwrap();
1145 let missing = dir.path().join("gone.rs");
1146
1147 let result = scan_files(&[missing], &[unwrap_rule()], &Config::default()).unwrap();
1148 assert_eq!(result.findings.len(), 0);
1149 assert_eq!(result.stats.files_scanned, 0);
1150 }
1151
1152 #[test]
1153 fn scan_files_respects_config_ignores() {
1154 let dir = tempdir().unwrap();
1155 let gen = dir.path().join("generated");
1156 create_dir(&gen).unwrap();
1157 let out = gen.join("out.rs");
1158 write(&out, "fn g() { foo().unwrap(); }\n").unwrap();
1159
1160 let mut cfg = Config::default();
1161 cfg.scan.ignores = vec!["**/generated/**".to_string()];
1162 let result = scan_files(&[out], &[unwrap_rule()], &cfg).unwrap();
1163 assert_eq!(result.findings.len(), 0);
1164 assert_eq!(result.stats.files_scanned, 0);
1165 }
1166
1167 #[test]
1168 fn scan_files_cached_keeps_other_entries() {
1169 let src_dir = tempdir().unwrap();
1170 let cache_dir = tempdir().unwrap();
1171 let cache_path = cache_dir.path().join("cache");
1172 let a = src_dir.path().join("a.rs");
1173 let b = src_dir.path().join("b.rs");
1174 write(&a, "fn a() {\n foo().unwrap();\n}\n").unwrap();
1175 write(&b, "fn b() {\n bar().unwrap();\n}\n").unwrap();
1176
1177 let rules = [unwrap_rule()];
1178 let config = Config::default();
1179 let paths = [src_dir.path().to_path_buf()];
1180
1181 scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1182
1183 scan_files_cached(&[a], &rules, &config, &cache_path).unwrap();
1185
1186 let result = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1187 let stats = result.cache_stats.as_ref().unwrap();
1188 assert_eq!(stats.cached, 2, "a partial scan must not prune");
1189 assert_eq!(stats.changed, 0);
1190 }
1191
1192 fn metric_rule(body: &str) -> Rule {
1193 parse_rule(body).unwrap()
1194 }
1195
1196 fn file_lines_rule() -> Rule {
1197 metric_rule(
1198 r#"
1199id: test-file-lines
1200language: rust
1201severity: warning
1202category: slop
1203metric: file_lines
1204threshold: 10
1205message: "File is too long"
1206"#,
1207 )
1208 }
1209
1210 fn rust_lines(n: usize) -> String {
1212 (0..n).map(|i| format!("// line {i}\n")).collect()
1213 }
1214
1215 #[test]
1216 fn scan_finds_file_lines_violation() {
1217 let dir = tempdir().unwrap();
1218 write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1219 let result = scan_dir(dir.path(), &[file_lines_rule()]);
1220 assert_eq!(result.findings.len(), 1);
1221 let f = &result.findings[0];
1222 assert_eq!(f.rule_id, RuleId::from("test-file-lines"));
1223 assert_eq!(f.line, 1);
1224 assert_eq!(f.column, 1);
1225 assert_eq!(f.end_line, 1);
1226 assert_eq!(f.end_column, 1);
1227 assert_eq!(f.matched_text, "12 lines");
1228 }
1229
1230 #[test]
1231 fn metric_message_interpolates_value() {
1232 let dir = tempdir().unwrap();
1233 write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1234 let rule = metric_rule(
1235 r#"
1236id: test-interpolate
1237language: rust
1238severity: warning
1239category: slop
1240metric: file_lines
1241threshold: 10
1242message: "File exceeds 10 lines ($value lines)"
1243"#,
1244 );
1245 let result = scan_dir(dir.path(), &[rule]);
1246 assert_eq!(result.findings.len(), 1);
1247 let message = &result.findings[0].message;
1248 assert!(message.contains("(12 lines)"), "got: {message}");
1249 assert!(!message.contains("$value"), "got: {message}");
1250 }
1251
1252 #[test]
1253 fn metric_rule_below_threshold_no_finding() {
1254 let dir = tempdir().unwrap();
1255 write(dir.path().join("exact.rs"), rust_lines(10)).unwrap();
1256 let result = scan_dir(dir.path(), &[file_lines_rule()]);
1257 assert_eq!(
1258 result.findings.len(),
1259 0,
1260 "a file of exactly the threshold length must not fire"
1261 );
1262 }
1263
1264 #[test]
1265 fn metric_comment_ratio_finding() {
1266 let dir = tempdir().unwrap();
1267 write(
1268 dir.path().join("commented.rs"),
1269 "// a\n// b\n// c\n// d\nfn f() {}\nfn g() {}\nfn h() {}\n",
1270 )
1271 .unwrap();
1272 let rule = metric_rule(
1273 r#"
1274id: test-comment-ratio
1275language: rust
1276severity: warning
1277category: slop
1278metric: comment_ratio
1279threshold: 0.4
1280message: "Too many comments ($value)"
1281"#,
1282 );
1283 let result = scan_dir(dir.path(), &[rule]);
1284 assert_eq!(result.findings.len(), 1);
1285 assert!(
1286 result.findings[0].matched_text.ends_with("comment ratio"),
1287 "got: {}",
1288 result.findings[0].matched_text
1289 );
1290 }
1291
1292 #[test]
1293 fn metric_rule_respects_ignores_glob() {
1294 let dir = tempdir().unwrap();
1295 let gen = dir.path().join("generated");
1296 create_dir(&gen).unwrap();
1297 write(gen.join("out.rs"), rust_lines(12)).unwrap();
1298 write(dir.path().join("main.rs"), rust_lines(12)).unwrap();
1299 let rule = metric_rule(
1300 r#"
1301id: test-metric-ignores
1302language: rust
1303severity: warning
1304category: slop
1305metric: file_lines
1306threshold: 10
1307message: "too long"
1308ignores:
1309 - "**/generated/**"
1310"#,
1311 );
1312 let result = scan_dir(dir.path(), &[rule]);
1313 assert_eq!(result.findings.len(), 1);
1314 assert!(result.findings[0]
1315 .file
1316 .to_string_lossy()
1317 .contains("main.rs"));
1318 }
1319
1320 #[test]
1321 fn metric_rule_respects_files_glob() {
1322 let dir = tempdir().unwrap();
1323 let src = dir.path().join("src");
1324 create_dir(&src).unwrap();
1325 write(src.join("lib.rs"), rust_lines(12)).unwrap();
1326 write(dir.path().join("build.rs"), rust_lines(12)).unwrap();
1327 let rule = metric_rule(
1328 r#"
1329id: test-metric-files
1330language: rust
1331severity: warning
1332category: slop
1333metric: file_lines
1334threshold: 10
1335message: "too long"
1336files:
1337 - "**/src/**/*.rs"
1338"#,
1339 );
1340 let result = scan_dir(dir.path(), &[rule]);
1341 assert_eq!(result.findings.len(), 1);
1342 assert!(result.findings[0].file.to_string_lossy().contains("lib.rs"));
1343 }
1344
1345 #[test]
1346 fn metric_rule_skip_test_code_skips_test_files() {
1347 let dir = tempdir().unwrap();
1348 let tests_dir = dir.path().join("tests");
1349 create_dir(&tests_dir).unwrap();
1350 write(tests_dir.join("it.rs"), rust_lines(12)).unwrap();
1351
1352 let skipping = metric_rule(
1353 r#"
1354id: test-metric-skip
1355language: rust
1356severity: warning
1357category: slop
1358metric: file_lines
1359threshold: 10
1360message: "too long"
1361skip_test_code: true
1362"#,
1363 );
1364 let skipped = scan_dir(dir.path(), &[skipping]);
1365 assert_eq!(skipped.findings.len(), 0);
1366 let reported = scan_dir(dir.path(), &[file_lines_rule()]);
1367 assert_eq!(reported.findings.len(), 1);
1368 }
1369
1370 #[test]
1371 fn metric_only_ruleset_still_scans() {
1372 let dir = tempdir().unwrap();
1373 write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1374 let result = scan_dir(dir.path(), &[file_lines_rule()]);
1375 assert_eq!(
1376 result.findings.len(),
1377 1,
1378 "a rule set with only metric rules must still scan files"
1379 );
1380 }
1381
1382 #[test]
1383 fn metric_and_ast_rules_together() {
1384 let dir = tempdir().unwrap();
1385 let mut source = String::from("fn main() {\n foo().unwrap();\n}\n");
1386 source.push_str(&rust_lines(12));
1387 write(dir.path().join("main.rs"), source).unwrap();
1388
1389 let result = scan_dir(dir.path(), &[unwrap_rule(), file_lines_rule()]);
1390 assert_eq!(result.findings.len(), 2);
1391 let first = &result.findings[0];
1392 assert_eq!(first.rule_id, RuleId::from("test-file-lines"));
1393 assert_eq!(first.line, 1);
1394 let second = &result.findings[1];
1395 assert_eq!(second.rule_id, RuleId::from("test-unwrap"));
1396 }
1397
1398 #[test]
1399 fn metric_rule_wrong_language_not_applied() {
1400 let dir = tempdir().unwrap();
1401 write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1402 let rule = metric_rule(
1403 r#"
1404id: test-metric-ts
1405language: typescript
1406severity: warning
1407category: slop
1408metric: file_lines
1409threshold: 10
1410message: "too long"
1411"#,
1412 );
1413 let result = scan_dir(dir.path(), &[rule]);
1414 assert_eq!(result.findings.len(), 0);
1415 }
1416
1417 #[test]
1418 fn metric_findings_are_cached() {
1419 let src_dir = tempdir().unwrap();
1420 let cache_dir = tempdir().unwrap();
1421 let cache_path = cache_dir.path().join("cache");
1422 write(src_dir.path().join("big.rs"), rust_lines(12)).unwrap();
1423
1424 let rules = [file_lines_rule()];
1425 let config = Config::default();
1426 let paths = [src_dir.path().to_path_buf()];
1427
1428 let first = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1429 assert_eq!(first.findings.len(), 1);
1430 assert_eq!(first.cache_stats.as_ref().unwrap().changed, 1);
1431
1432 let second = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1433 assert_eq!(second.cache_stats.as_ref().unwrap().cached, 1);
1434 assert_eq!(second.findings.len(), 1);
1435 assert_eq!(second.findings[0].matched_text, "12 lines");
1436 }
1437
1438 #[test]
1439 fn scan_with_cache() {
1440 let src_dir = tempdir().unwrap();
1441 let cache_dir = tempdir().unwrap();
1442 let cache_path = cache_dir.path().join("cache");
1443 write(
1444 src_dir.path().join("main.rs"),
1445 "fn main() {\n foo().unwrap();\n}\n",
1446 )
1447 .unwrap();
1448
1449 let rules = [unwrap_rule()];
1450 let config = Config::default();
1451
1452 let result1 = scan_cached(
1453 &[src_dir.path().to_path_buf()],
1454 &rules,
1455 &config,
1456 &cache_path,
1457 )
1458 .unwrap();
1459
1460 assert_eq!(result1.findings.len(), 1);
1461 let cache_stats1 = result1.cache_stats.as_ref().unwrap();
1462 assert_eq!(cache_stats1.cached, 0, "first scan: nothing cached");
1463 assert_eq!(cache_stats1.changed, 1, "first scan: one file scanned");
1464
1465 let result2 = scan_cached(
1466 &[src_dir.path().to_path_buf()],
1467 &rules,
1468 &config,
1469 &cache_path,
1470 )
1471 .unwrap();
1472
1473 assert_eq!(result2.findings.len(), 1);
1474 let cache_stats2 = result2.cache_stats.as_ref().unwrap();
1475 assert_eq!(cache_stats2.cached, 1, "second scan: one file from cache");
1476 assert_eq!(cache_stats2.changed, 0, "second scan: nothing changed");
1477 }
1478
1479 fn single_impl_trait_rule() -> Rule {
1480 parse_rule(
1481 r#"
1482id: no-single-impl-trait
1483language: rust
1484severity: warning
1485category: slop
1486cross_file: single_impl_trait
1487message: "Trait with a single implementation in the project."
1488skip_test_code: true
1489"#,
1490 )
1491 .unwrap()
1492 }
1493
1494 #[test]
1495 fn cross_file_single_impl_trait_reported() {
1496 let dir = tempdir().unwrap();
1497 write(
1498 dir.path().join("repo.rs"),
1499 "pub trait Repository {\n fn get(&self);\n}\n",
1500 )
1501 .unwrap();
1502 write(
1503 dir.path().join("pg.rs"),
1504 "pub struct Pg;\n\nimpl Repository for Pg {\n fn get(&self) {}\n}\n",
1505 )
1506 .unwrap();
1507
1508 let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1509 assert_eq!(result.findings.len(), 1);
1510 let f = &result.findings[0];
1511 assert_eq!(f.rule_id, RuleId::from("no-single-impl-trait"));
1512 assert!(f.file.to_string_lossy().ends_with("repo.rs"));
1513 assert_eq!(f.line, 1);
1514 assert_eq!(f.matched_text, "pub trait Repository");
1515 }
1516
1517 #[test]
1518 fn cross_file_two_impls_not_reported() {
1519 let dir = tempdir().unwrap();
1520 write(dir.path().join("repo.rs"), "pub trait Repository {}\n").unwrap();
1521 write(dir.path().join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1522 write(dir.path().join("mem.rs"), "impl Repository for Mem {}\n").unwrap();
1523
1524 let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1525 assert_eq!(result.findings.len(), 0);
1526 }
1527
1528 #[test]
1529 fn cross_file_no_impl_not_reported() {
1530 let dir = tempdir().unwrap();
1531 write(dir.path().join("repo.rs"), "pub trait Repository {}\n").unwrap();
1532
1533 let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1534 assert_eq!(result.findings.len(), 0);
1535 }
1536
1537 #[test]
1538 fn cross_file_blanket_impl_not_reported() {
1539 let dir = tempdir().unwrap();
1540 write(dir.path().join("desc.rs"), "pub trait Describe {}\n").unwrap();
1541 write(dir.path().join("all.rs"), "impl<T> Describe for T {}\n").unwrap();
1542
1543 let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1544 assert_eq!(result.findings.len(), 0);
1545 }
1546
1547 #[test]
1548 fn cross_file_duplicate_trait_name_not_reported() {
1549 let dir = tempdir().unwrap();
1550 write(dir.path().join("a.rs"), "pub trait Repository {}\n").unwrap();
1551 write(dir.path().join("b.rs"), "pub trait Repository {}\n").unwrap();
1552 write(dir.path().join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1553
1554 let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1555 assert_eq!(result.findings.len(), 0);
1556 }
1557
1558 #[test]
1559 fn cross_file_disable_comment_on_declaration() {
1560 let dir = tempdir().unwrap();
1561 write(
1562 dir.path().join("repo.rs"),
1563 "// slopguard-disable-next-line no-single-impl-trait\npub trait Repository {}\n",
1564 )
1565 .unwrap();
1566 write(dir.path().join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1567
1568 let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1569 assert_eq!(
1570 result.findings.len(),
1571 0,
1572 "a disable comment above the declaration must suppress the finding"
1573 );
1574 }
1575
1576 #[test]
1577 fn cross_file_cfg_test_mock_counts_as_second_impl() {
1578 let dir = tempdir().unwrap();
1579 write(dir.path().join("repo.rs"), "pub trait Repository {}\n").unwrap();
1580 write(dir.path().join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1581 write(
1582 dir.path().join("mock.rs"),
1583 "#[cfg(test)]\nmod tests {\n struct MockRepo;\n impl Repository for MockRepo {}\n}\n",
1584 )
1585 .unwrap();
1586
1587 let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1588 assert_eq!(
1589 result.findings.len(),
1590 0,
1591 "a test mock counts as a second implementation"
1592 );
1593 }
1594
1595 #[test]
1596 fn cross_file_declaration_in_test_path_not_reported() {
1597 let dir = tempdir().unwrap();
1598 let tests_dir = dir.path().join("tests");
1599 create_dir(&tests_dir).unwrap();
1600 write(tests_dir.join("support.rs"), "pub trait Repository {}\n").unwrap();
1601 write(tests_dir.join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1602
1603 let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1604 assert_eq!(result.findings.len(), 0);
1605 }
1606
1607 #[test]
1608 fn cross_file_not_run_for_scan_files() {
1609 let dir = tempdir().unwrap();
1610 let repo = dir.path().join("repo.rs");
1611 let pg = dir.path().join("pg.rs");
1612 write(&repo, "pub trait Repository {}\n").unwrap();
1613 write(&pg, "impl Repository for Pg {}\n").unwrap();
1614
1615 let rules = [single_impl_trait_rule()];
1616 let partial = scan_files(&[repo, pg], &rules, &Config::default()).unwrap();
1617 assert_eq!(
1618 partial.findings.len(),
1619 0,
1620 "an explicit file list has an incomplete project index"
1621 );
1622
1623 let full = scan_dir(dir.path(), &rules);
1624 assert_eq!(full.findings.len(), 1);
1625 }
1626
1627 #[test]
1628 fn cross_file_survives_cache_round_trip() {
1629 let src_dir = tempdir().unwrap();
1630 let cache_dir = tempdir().unwrap();
1631 let cache_path = cache_dir.path().join("cache");
1632 write(src_dir.path().join("repo.rs"), "pub trait Repository {}\n").unwrap();
1633 write(src_dir.path().join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1634
1635 let rules = [single_impl_trait_rule()];
1636 let config = Config::default();
1637 let paths = [src_dir.path().to_path_buf()];
1638
1639 let first = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1640 assert_eq!(first.findings.len(), 1);
1641 assert_eq!(first.cache_stats.as_ref().unwrap().changed, 2);
1642
1643 let second = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1644 let stats = second.cache_stats.as_ref().unwrap();
1645 assert_eq!(stats.cached, 2, "both files should come from the cache");
1646 assert_eq!(stats.changed, 0);
1647 assert_eq!(
1648 second.findings.len(),
1649 1,
1650 "symbols must survive the cache round trip"
1651 );
1652 }
1653
1654 #[test]
1655 fn cross_file_rule_with_no_ast_rules_still_parses_files() {
1656 let dir = tempdir().unwrap();
1657 write(dir.path().join("repo.rs"), "pub trait Repository {}\n").unwrap();
1658 write(dir.path().join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1659
1660 let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1661 assert_eq!(
1662 result.findings.len(),
1663 1,
1664 "a cross-file-only ruleset must still parse every file"
1665 );
1666 }
1667}