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_language::SupportLang;
11use globset::{Error as GlobError, Glob, GlobSet, GlobSetBuilder};
12use ignore::WalkBuilder;
13use rayon::prelude::*;
14use serde::Serialize;
15use serde_yaml::with::singleton_map_recursive;
16use serde_yaml::{to_value, Value};
17use strum::IntoEnumIterator;
18use thiserror::Error;
19
20use crate::cache::{file_content_hash, rules_hash, CacheStore};
21use crate::config::Config;
22use crate::disable::filter_disabled;
23use crate::finding::{CacheStats, Finding, ScanResult, ScanStats};
24use crate::rule::{Language, Rule, RuleId, Severity};
25use crate::test_filter::CfgTestRanges;
26
27#[derive(Debug, Error)]
28pub enum ScanError {
29 #[error("failed to compile rule '{id}': {source}")]
30 RuleCompile {
31 id: RuleId,
32 #[source]
33 source: RuleConfigError,
34 },
35
36 #[error("invalid glob pattern: {0}")]
37 Glob(#[from] GlobError),
38}
39
40#[derive(Serialize)]
43pub struct AstGrepRule<'a> {
44 pub id: &'a str,
45 pub language: SupportLang,
46 pub severity: &'a Severity,
47 pub message: &'a str,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub note: Option<&'a str>,
50 pub rule: &'a Value,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub files: Option<&'a [String]>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub ignores: Option<&'a [String]>,
55}
56
57struct CompiledRules<'a> {
60 collection: RuleCollection<SupportLang>,
61 by_id: HashMap<(&'a str, Language), &'a Rule>,
62}
63
64fn extension_to_lang(path: &Path) -> Option<SupportLang> {
65 SupportLang::from_path(path)
66 .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
67}
68
69fn support_lang_to_language(lang: SupportLang) -> Language {
70 match lang {
71 SupportLang::Rust => Language::Rust,
72 _ => Language::TypeScript,
73 }
74}
75
76pub(crate) fn compile_ast_grep_rule(
77 rule: &AstGrepRule,
78) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
79 let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
80 .and_then(singleton_map_recursive::deserialize)
81 .map_err(RuleConfigError::from)?;
82 RuleConfig::try_from(inner, &GlobalRules::default())
83}
84
85fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
86 let ast_grep_rule = AstGrepRule {
87 id: rule.id.as_str(),
88 language: lang,
89 severity: &rule.severity,
90 message: &rule.message,
91 note: rule.note.as_deref(),
92 rule: &rule.rule,
93 files: rule.files.as_deref(),
94 ignores: rule.ignores.as_deref(),
95 };
96 compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
97 id: rule.id.clone(),
98 source,
99 })
100}
101
102fn compile_rules(rules: &[Rule]) -> Result<CompiledRules<'_>, ScanError> {
103 let configs = rules
104 .iter()
105 .flat_map(|rule| {
106 rule.language
107 .ast_grep_langs()
108 .iter()
109 .map(move |lang| compile_rule(rule, *lang))
110 })
111 .collect::<Result<Vec<_>, _>>()?;
112 Ok(CompiledRules {
113 collection: RuleCollection::try_new(configs)?,
114 by_id: rules
115 .iter()
116 .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
117 .collect::<HashMap<_, _>>(),
118 })
119}
120
121fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
122 let mut builder = GlobSetBuilder::new();
123 for pattern in patterns {
124 builder.add(Glob::new(pattern)?);
125 }
126 builder.build()
127}
128
129fn is_test_path(path: &Path) -> bool {
139 let in_test_dir = path.components().any(|c| {
140 let Some(name) = c.as_os_str().to_str() else {
141 return false;
142 };
143 matches!(name, "tests" | "benches" | "examples")
144 || name.ends_with("_tests")
145 || name.ends_with("_test")
146 || name.starts_with("test_")
147 });
148 let test_file_name = path
149 .file_stem()
150 .and_then(|s| s.to_str())
151 .is_some_and(|s| s.ends_with("_test") || s.ends_with("_tests"));
152 in_test_dir || test_file_name
153}
154
155fn scan_file(path: &Path, lang: SupportLang, rules: &CompiledRules) -> Vec<Finding> {
156 let applicable = rules.collection.get_rule_from_lang(path, lang);
157 if applicable.is_empty() {
158 return Vec::new();
159 }
160 let Ok(source) = read_to_string(path) else {
161 return Vec::new();
162 };
163
164 let root = lang.ast_grep(&source);
165 let combined = CombinedScan::new(applicable);
166 let result = combined.scan(&root, false);
167
168 let cfg_test = (lang == SupportLang::Rust && !result.matches.is_empty())
169 .then(|| CfgTestRanges::from_root(&root));
170 let cfg_test = cfg_test.as_ref();
171 let is_test_file = is_test_path(path);
172
173 let findings = result
174 .matches
175 .into_iter()
176 .filter_map(|(config, matches)| {
177 let rule_lang = support_lang_to_language(lang);
178 let rule = rules.by_id.get(&(config.id.as_str(), rule_lang))?;
179 Some((*rule, matches))
180 })
181 .flat_map(|(rule, matches)| {
182 let skip_test_code = rule.skip_test_code;
183 matches
184 .into_iter()
185 .map(move |node_match| {
186 let start = node_match.start_pos();
187 let end = node_match.end_pos();
188 Finding {
189 rule_id: rule.id.clone(),
190 severity: rule.severity.clone(),
191 category: rule.category.clone().unwrap_or_default(),
192 message: rule.message.clone(),
193 note: rule.note.clone(),
194 fix: rule.fix.clone(),
195 file: path.to_path_buf(),
196 line: start.line() + 1,
197 column: start.byte_point().1 + 1,
198 end_line: end.line() + 1,
199 end_column: end.byte_point().1 + 1,
200 matched_text: node_match.text().to_string(),
201 confidence: None,
202 }
203 })
204 .filter(move |f| {
205 if !skip_test_code {
206 return true;
207 }
208 let in_test_code =
209 is_test_file || cfg_test.is_some_and(|r| r.contains_line(f.line));
210 !in_test_code
211 })
212 })
213 .collect();
214
215 filter_disabled(findings, &source)
216}
217
218fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
220 paths
221 .iter()
222 .flat_map(|path| WalkBuilder::new(path).build().flatten())
223 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
224 .map(|entry| entry.into_path())
225 .filter(|path| !ignores.is_match(path))
226 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
227 .collect()
228}
229
230fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
236 files
237 .iter()
238 .filter(|path| path.is_file())
239 .filter(|path| !ignores.is_match(path))
240 .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
241 .collect()
242}
243
244pub fn count_severities(findings: &[Finding]) -> (usize, usize) {
246 let mut errors = 0;
247 let mut warnings = 0;
248 for finding in findings {
249 match finding.severity {
250 Severity::Error => errors += 1,
251 Severity::Warning => warnings += 1,
252 }
253 }
254 (errors, warnings)
255}
256
257fn normalize_findings(findings: &mut Vec<Finding>) {
259 findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
260 findings.dedup_by(|a, b| {
261 a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
262 });
263}
264
265fn scan_collected(files: Vec<(PathBuf, SupportLang)>, compiled: &CompiledRules) -> ScanResult {
267 let mut findings: Vec<Finding> = files
268 .par_iter()
269 .flat_map_iter(|(path, lang)| scan_file(path, *lang, compiled))
270 .collect();
271 normalize_findings(&mut findings);
272
273 let (errors, warnings) = count_severities(&findings);
274
275 ScanResult {
276 findings,
277 stats: ScanStats {
278 errors,
279 warnings,
280 total: errors + warnings,
281 files_scanned: files.len(),
282 baseline_filtered: 0,
283 diff_base: None,
284 files_changed: None,
285 },
286 cache_stats: None,
287 }
288}
289
290fn scan_collected_cached(
297 files: Vec<(PathBuf, SupportLang)>,
298 compiled: &CompiledRules,
299 rules: &[Rule],
300 cache_dir: &Path,
301 prune: bool,
302) -> ScanResult {
303 let store = CacheStore::with_dir(cache_dir.to_path_buf());
304 let current_rules_hash = rules_hash(rules);
305 let rules_changed = store
306 .check_rules_changed(¤t_rules_hash)
307 .unwrap_or(true);
308
309 let file_contents: Vec<(PathBuf, SupportLang, Vec<u8>, String)> = files
310 .into_iter()
311 .filter_map(|(path, lang)| {
312 let content = fs::read(&path).ok()?;
313 let hash = file_content_hash(&content);
314 Some((path, lang, content, hash))
315 })
316 .collect();
317
318 let mut cached_count = 0usize;
319 let mut changed_count = 0usize;
320 let mut all_findings: Vec<Finding> = Vec::new();
321
322 struct FileWork {
323 path: PathBuf,
324 lang: SupportLang,
325 hash: String,
326 }
327
328 let mut to_scan: Vec<FileWork> = Vec::new();
329
330 for (path, lang, _content, hash) in file_contents.iter() {
331 if !rules_changed {
332 if let Some(cached_findings) = store.get(hash) {
333 cached_count += 1;
334 all_findings.extend(cached_findings);
335 continue;
336 }
337 }
338 changed_count += 1;
339 to_scan.push(FileWork {
340 path: path.clone(),
341 lang: *lang,
342 hash: hash.clone(),
343 });
344 }
345
346 let scanned_findings: Vec<(String, Vec<Finding>)> = to_scan
347 .par_iter()
348 .map(|work| {
349 let findings = scan_file(&work.path, work.lang, compiled);
350 (work.hash.clone(), findings)
351 })
352 .collect();
353
354 for (hash, findings) in scanned_findings {
355 store.put(&hash, &findings).ok();
356 all_findings.extend(findings);
357 }
358
359 if prune {
360 let current_hashes: Vec<String> =
361 file_contents.iter().map(|(_, _, _, h)| h.clone()).collect();
362 store.cleanup(¤t_hashes).ok();
363 }
364
365 normalize_findings(&mut all_findings);
366
367 let (errors, warnings) = count_severities(&all_findings);
368
369 let files_scanned = cached_count + changed_count;
370
371 ScanResult {
372 findings: all_findings,
373 stats: ScanStats {
374 errors,
375 warnings,
376 total: errors + warnings,
377 files_scanned,
378 baseline_filtered: 0,
379 diff_base: None,
380 files_changed: None,
381 },
382 cache_stats: Some(CacheStats {
383 cached: cached_count,
384 changed: changed_count,
385 }),
386 }
387}
388
389pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
391 let compiled = compile_rules(rules)?;
392 let ignores = build_glob_set(&config.scan.ignores)?;
393 Ok(scan_collected(walk_files(paths, &ignores), &compiled))
394}
395
396pub fn scan_cached(
405 paths: &[PathBuf],
406 rules: &[Rule],
407 config: &Config,
408 cache_dir: &Path,
409) -> Result<ScanResult, ScanError> {
410 let compiled = compile_rules(rules)?;
411 let ignores = build_glob_set(&config.scan.ignores)?;
412 Ok(scan_collected_cached(
413 walk_files(paths, &ignores),
414 &compiled,
415 rules,
416 cache_dir,
417 true,
418 ))
419}
420
421pub fn scan_files(
426 files: &[PathBuf],
427 rules: &[Rule],
428 config: &Config,
429) -> Result<ScanResult, ScanError> {
430 let compiled = compile_rules(rules)?;
431 let ignores = build_glob_set(&config.scan.ignores)?;
432 Ok(scan_collected(explicit_files(files, &ignores), &compiled))
433}
434
435pub fn scan_files_cached(
438 files: &[PathBuf],
439 rules: &[Rule],
440 config: &Config,
441 cache_dir: &Path,
442) -> Result<ScanResult, ScanError> {
443 let compiled = compile_rules(rules)?;
444 let ignores = build_glob_set(&config.scan.ignores)?;
445 Ok(scan_collected_cached(
446 explicit_files(files, &ignores),
447 &compiled,
448 rules,
449 cache_dir,
450 false,
451 ))
452}
453
454#[cfg(test)]
455mod tests {
456 use std::fs::{create_dir, write};
457
458 use tempfile::tempdir;
459
460 use super::*;
461 use crate::rule::{load_builtin_rules, parse_rule, RuleId};
462
463 fn unwrap_rule() -> Rule {
464 parse_rule(
465 r#"
466id: test-unwrap
467language: rust
468severity: error
469category: correctness
470message: ".unwrap() forbidden"
471rule:
472 kind: call_expression
473 regex: '\.unwrap\(\)\s*$'
474"#,
475 )
476 .unwrap()
477 }
478
479 fn scan_dir(dir: &Path, rules: &[Rule]) -> ScanResult {
480 scan(&[dir.to_path_buf()], rules, &Config::default()).unwrap()
481 }
482
483 #[test]
484 fn scan_finds_unwrap_violation() {
485 let dir = tempdir().unwrap();
486 write(
487 dir.path().join("main.rs"),
488 "fn main() {\n foo().unwrap();\n}\n",
489 )
490 .unwrap();
491 let result = scan_dir(dir.path(), &[unwrap_rule()]);
492 assert_eq!(result.findings.len(), 1);
493 let f = &result.findings[0];
494 assert_eq!(f.rule_id, RuleId::from("test-unwrap"));
495 assert_eq!(f.severity, Severity::Error);
496 assert_eq!(f.line, 2);
497 assert!(f.matched_text.contains("unwrap()"));
498 }
499
500 #[test]
501 fn scan_clean_file_no_findings() {
502 let dir = tempdir().unwrap();
503 write(dir.path().join("main.rs"), "fn main() { let x = foo(); }\n").unwrap();
504 let result = scan_dir(dir.path(), &[unwrap_rule()]);
505 assert_eq!(result.findings.len(), 0);
506 assert_eq!(result.stats.files_scanned, 1);
507 }
508
509 #[test]
510 fn scan_unsupported_language_ignored() {
511 let dir = tempdir().unwrap();
512 write(dir.path().join("script.py"), "x.unwrap()\n").unwrap();
513 write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
514 let result = scan_dir(dir.path(), &[unwrap_rule()]);
515 assert_eq!(result.findings.len(), 0);
516 assert_eq!(result.stats.files_scanned, 1);
517 }
518
519 #[test]
520 fn scan_explicit_file_path() {
521 let dir = tempdir().unwrap();
522 let file = dir.path().join("main.rs");
523 write(&file, "fn main() { foo().unwrap(); }\n").unwrap();
524 let result = scan(&[file], &[unwrap_rule()], &Config::default()).unwrap();
525 assert_eq!(result.findings.len(), 1);
526 assert_eq!(result.stats.files_scanned, 1);
527 }
528
529 #[test]
530 fn rule_ignores_glob_respected() {
531 let dir = tempdir().unwrap();
532 let tests_dir = dir.path().join("tests");
533 create_dir(&tests_dir).unwrap();
534 write(
535 tests_dir.join("test_main.rs"),
536 "fn t() { foo().unwrap(); }\n",
537 )
538 .unwrap();
539 write(
540 dir.path().join("main.rs"),
541 "fn main() { foo().unwrap(); }\n",
542 )
543 .unwrap();
544 let rule = parse_rule(
545 r#"
546id: ign
547language: rust
548severity: error
549category: correctness
550message: x
551rule:
552 kind: call_expression
553 regex: '\.unwrap\(\)\s*$'
554ignores:
555 - "**/tests/**"
556"#,
557 )
558 .unwrap();
559 let result = scan_dir(dir.path(), &[rule]);
560 assert_eq!(result.findings.len(), 1);
561 assert!(result.findings[0]
562 .file
563 .to_string_lossy()
564 .contains("main.rs"));
565 }
566
567 #[test]
568 fn config_ignores_respected() {
569 let dir = tempdir().unwrap();
570 let gen = dir.path().join("generated");
571 create_dir(&gen).unwrap();
572 write(gen.join("out.rs"), "fn g() { foo().unwrap(); }\n").unwrap();
573 write(
574 dir.path().join("main.rs"),
575 "fn main() { foo().unwrap(); }\n",
576 )
577 .unwrap();
578 let mut cfg = Config::default();
579 cfg.scan.ignores = vec!["**/generated/**".to_string()];
580 let result = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap();
581 assert_eq!(result.stats.files_scanned, 1);
582 assert_eq!(result.findings.len(), 1);
583 }
584
585 #[test]
586 fn config_invalid_glob_is_an_error() {
587 let dir = tempdir().unwrap();
588 let mut cfg = Config::default();
589 cfg.scan.ignores = vec!["[".to_string()];
590 let err = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap_err();
591 assert!(matches!(err, ScanError::Glob(_)), "{err:?}");
592 }
593
594 #[test]
595 fn rule_without_kind_fails_to_compile() {
596 let dir = tempdir().unwrap();
597 let rule = parse_rule(
598 r#"
599id: no-kind
600language: rust
601severity: error
602category: correctness
603message: x
604rule:
605 regex: 'unwrap'
606"#,
607 )
608 .unwrap();
609 let err = scan(&[dir.path().to_path_buf()], &[rule], &Config::default()).unwrap_err();
610 assert!(
611 matches!(&err, ScanError::RuleCompile { id, .. } if *id == RuleId::from("no-kind")),
612 "{err:?}"
613 );
614 }
615
616 #[test]
617 fn stats_counting_accurate() {
618 let dir = tempdir().unwrap();
619 write(
620 dir.path().join("a.rs"),
621 "fn f() { foo().unwrap(); bar().unwrap(); }\n",
622 )
623 .unwrap();
624 let result = scan_dir(dir.path(), &[unwrap_rule()]);
625 assert_eq!(result.stats.errors, 2);
626 assert_eq!(result.stats.warnings, 0);
627 assert_eq!(result.stats.total, 2);
628 assert_eq!(result.stats.files_scanned, 1);
629 }
630
631 #[test]
632 fn scan_empty_file() {
633 let dir = tempdir().unwrap();
634 write(dir.path().join("empty.rs"), "").unwrap();
635 let result = scan_dir(dir.path(), &[unwrap_rule()]);
636 assert_eq!(result.findings.len(), 0);
637 assert_eq!(result.stats.files_scanned, 1);
638 }
639
640 #[test]
641 fn scan_complex_rule() {
642 let rule = parse_rule(
643 r#"
644id: dbg
645language: rust
646severity: error
647category: security
648message: debug on secret
649rule:
650 kind: attribute_item
651 all:
652 - regex: 'derive\([^)]*Debug'
653 - precedes:
654 kind: struct_item
655 has:
656 kind: field_identifier
657 regex: '^(api_key|password|secret)$'
658 stopBy: end
659"#,
660 )
661 .unwrap();
662 let dir = tempdir().unwrap();
663 write(
664 dir.path().join("t.rs"),
665 "#[derive(Debug)]\nstruct Config {\n api_key: String,\n}\n",
666 )
667 .unwrap();
668 let result = scan_dir(dir.path(), &[rule]);
669 assert_eq!(result.findings.len(), 1);
670 assert_eq!(result.findings[0].rule_id, RuleId::from("dbg"));
671 }
672
673 #[test]
674 fn typescript_rule_applies_to_tsx_files() {
675 let rule = parse_rule(
676 r#"
677id: ts-any
678language: typescript
679severity: warning
680category: slop
681message: no any
682rule:
683 kind: predefined_type
684 regex: '^any$'
685"#,
686 )
687 .unwrap();
688 let dir = tempdir().unwrap();
689 write(dir.path().join("a.ts"), "let x: any = 1;\n").unwrap();
690 write(dir.path().join("b.tsx"), "let y: any = 2;\n").unwrap();
691 let result = scan_dir(dir.path(), &[rule]);
692 assert_eq!(result.stats.files_scanned, 2);
693 assert_eq!(result.stats.warnings, 2);
694 }
695
696 #[test]
697 fn scan_with_disable_comment_filters_findings() {
698 let dir = tempdir().unwrap();
699 write(
700 dir.path().join("main.rs"),
701 "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n bar().unwrap();\n}\n",
702 )
703 .unwrap();
704 let result = scan_dir(dir.path(), &[unwrap_rule()]);
705 assert_eq!(
706 result.findings.len(),
707 1,
708 "only the non-disabled unwrap should remain"
709 );
710 assert_eq!(result.findings[0].line, 4);
711 }
712
713 #[test]
714 fn scan_with_disable_rule_id_filters_only_that_rule() {
715 let dir = tempdir().unwrap();
716 write(
717 dir.path().join("main.rs"),
718 "fn main() {\n // slopguard-disable-next-line test-unwrap\n foo().unwrap();\n}\n",
719 )
720 .unwrap();
721 let result = scan_dir(dir.path(), &[unwrap_rule()]);
722 assert_eq!(result.findings.len(), 0);
723 }
724
725 fn unwrap_rule_skipping_test_code() -> Rule {
726 parse_rule(
727 r#"
728id: test-unwrap
729language: rust
730severity: error
731category: correctness
732message: ".unwrap() forbidden"
733rule:
734 kind: call_expression
735 regex: '\.unwrap\(\)\s*$'
736skip_test_code: true
737"#,
738 )
739 .unwrap()
740 }
741
742 #[test]
743 fn cfg_test_block_excluded_by_post_filter() {
744 let dir = tempdir().unwrap();
745 write(
746 dir.path().join("main.rs"),
747 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
748 )
749 .unwrap();
750 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
751 assert_eq!(
752 result.findings.len(),
753 1,
754 "should only find the prod unwrap, not the one inside #[cfg(test)]"
755 );
756 assert_eq!(result.findings[0].line, 2);
757 }
758
759 #[test]
760 fn cfg_test_kept_without_skip_test_code() {
761 let dir = tempdir().unwrap();
762 write(
763 dir.path().join("main.rs"),
764 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
765 )
766 .unwrap();
767 let result = scan_dir(dir.path(), &[unwrap_rule()]);
768 assert_eq!(
769 result.findings.len(),
770 2,
771 "rule without skip_test_code should find both"
772 );
773 }
774
775 #[test]
776 fn integration_test_file_excluded_by_skip_test_code() {
777 let dir = tempdir().unwrap();
778 let tests_dir = dir.path().join("tests");
779 std::fs::create_dir(&tests_dir).unwrap();
780 write(
781 tests_dir.join("it.rs"),
782 "fn check() {\n foo().unwrap();\n}\n",
783 )
784 .unwrap();
785 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
786 assert_eq!(
787 result.findings.len(),
788 0,
789 "unwrap inside a tests/ integration file should be skipped"
790 );
791 }
792
793 #[test]
794 fn integration_test_file_kept_without_skip_test_code() {
795 let dir = tempdir().unwrap();
796 let tests_dir = dir.path().join("tests");
797 std::fs::create_dir(&tests_dir).unwrap();
798 write(
799 tests_dir.join("it.rs"),
800 "fn check() {\n foo().unwrap();\n}\n",
801 )
802 .unwrap();
803 let result = scan_dir(dir.path(), &[unwrap_rule()]);
804 assert_eq!(
805 result.findings.len(),
806 1,
807 "rule without skip_test_code should still flag tests/ files"
808 );
809 }
810
811 #[test]
812 fn test_crate_directory_excluded_by_skip_test_code() {
813 let dir = tempdir().unwrap();
814 let test_crate = dir
815 .path()
816 .join("crates")
817 .join("integrations_tests")
818 .join("src");
819 std::fs::create_dir_all(&test_crate).unwrap();
820 write(
821 test_crate.join("api.rs"),
822 "fn check() {\n foo().unwrap();\n}\n",
823 )
824 .unwrap();
825 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
826 assert_eq!(
827 result.findings.len(),
828 0,
829 "unwrap inside a *_tests crate should be skipped"
830 );
831 }
832
833 #[test]
834 fn test_crate_directory_kept_without_skip_test_code() {
835 let dir = tempdir().unwrap();
836 let test_crate = dir
837 .path()
838 .join("crates")
839 .join("integrations_tests")
840 .join("src");
841 std::fs::create_dir_all(&test_crate).unwrap();
842 write(
843 test_crate.join("api.rs"),
844 "fn check() {\n foo().unwrap();\n}\n",
845 )
846 .unwrap();
847 let result = scan_dir(dir.path(), &[unwrap_rule()]);
848 assert_eq!(
849 result.findings.len(),
850 1,
851 "rule without skip_test_code should still flag *_tests crate files"
852 );
853 }
854
855 #[test]
856 fn all_builtin_rules_compile() {
857 let rules = load_builtin_rules().unwrap();
858 assert!(
859 rules.len() >= 55,
860 "expected at least 55 rules, got {}",
861 rules.len()
862 );
863 let compiled = compile_rules(&rules).expect("all builtin rules should compile");
864 assert_eq!(compiled.by_id.len(), rules.len());
865 }
866
867 #[test]
868 fn scan_files_only_scans_given_files() {
869 let dir = tempdir().unwrap();
870 let a = dir.path().join("a.rs");
871 let b = dir.path().join("b.rs");
872 write(&a, "fn a() {\n foo().unwrap();\n}\n").unwrap();
873 write(&b, "fn b() {\n bar().unwrap();\n}\n").unwrap();
874
875 let result = scan_files(&[a], &[unwrap_rule()], &Config::default()).unwrap();
876 assert_eq!(result.findings.len(), 1);
877 assert_eq!(result.stats.files_scanned, 1);
878 assert!(result.findings[0].file.to_string_lossy().contains("a.rs"));
879 }
880
881 #[test]
882 fn scan_files_skips_unsupported_language() {
883 let dir = tempdir().unwrap();
884 let py = dir.path().join("script.py");
885 write(&py, "x.unwrap()\n").unwrap();
886
887 let result = scan_files(&[py], &[unwrap_rule()], &Config::default()).unwrap();
888 assert_eq!(result.findings.len(), 0);
889 assert_eq!(result.stats.files_scanned, 0);
890 }
891
892 #[test]
893 fn scan_files_skips_missing_file() {
894 let dir = tempdir().unwrap();
895 let missing = dir.path().join("gone.rs");
896
897 let result = scan_files(&[missing], &[unwrap_rule()], &Config::default()).unwrap();
898 assert_eq!(result.findings.len(), 0);
899 assert_eq!(result.stats.files_scanned, 0);
900 }
901
902 #[test]
903 fn scan_files_respects_config_ignores() {
904 let dir = tempdir().unwrap();
905 let gen = dir.path().join("generated");
906 create_dir(&gen).unwrap();
907 let out = gen.join("out.rs");
908 write(&out, "fn g() { foo().unwrap(); }\n").unwrap();
909
910 let mut cfg = Config::default();
911 cfg.scan.ignores = vec!["**/generated/**".to_string()];
912 let result = scan_files(&[out], &[unwrap_rule()], &cfg).unwrap();
913 assert_eq!(result.findings.len(), 0);
914 assert_eq!(result.stats.files_scanned, 0);
915 }
916
917 #[test]
918 fn scan_files_cached_keeps_other_entries() {
919 let src_dir = tempdir().unwrap();
920 let cache_dir = tempdir().unwrap();
921 let cache_path = cache_dir.path().join("cache");
922 let a = src_dir.path().join("a.rs");
923 let b = src_dir.path().join("b.rs");
924 write(&a, "fn a() {\n foo().unwrap();\n}\n").unwrap();
925 write(&b, "fn b() {\n bar().unwrap();\n}\n").unwrap();
926
927 let rules = [unwrap_rule()];
928 let config = Config::default();
929 let paths = [src_dir.path().to_path_buf()];
930
931 scan_cached(&paths, &rules, &config, &cache_path).unwrap();
932
933 scan_files_cached(&[a], &rules, &config, &cache_path).unwrap();
935
936 let result = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
937 let stats = result.cache_stats.as_ref().unwrap();
938 assert_eq!(stats.cached, 2, "a partial scan must not prune");
939 assert_eq!(stats.changed, 0);
940 }
941
942 #[test]
943 fn scan_with_cache() {
944 let src_dir = tempdir().unwrap();
945 let cache_dir = tempdir().unwrap();
946 let cache_path = cache_dir.path().join("cache");
947 write(
948 src_dir.path().join("main.rs"),
949 "fn main() {\n foo().unwrap();\n}\n",
950 )
951 .unwrap();
952
953 let rules = [unwrap_rule()];
954 let config = Config::default();
955
956 let result1 = scan_cached(
957 &[src_dir.path().to_path_buf()],
958 &rules,
959 &config,
960 &cache_path,
961 )
962 .unwrap();
963
964 assert_eq!(result1.findings.len(), 1);
965 let cache_stats1 = result1.cache_stats.as_ref().unwrap();
966 assert_eq!(cache_stats1.cached, 0, "first scan: nothing cached");
967 assert_eq!(cache_stats1.changed, 1, "first scan: one file scanned");
968
969 let result2 = scan_cached(
970 &[src_dir.path().to_path_buf()],
971 &rules,
972 &config,
973 &cache_path,
974 )
975 .unwrap();
976
977 assert_eq!(result2.findings.len(), 1);
978 let cache_stats2 = result2.cache_stats.as_ref().unwrap();
979 assert_eq!(cache_stats2.cached, 1, "second scan: one file from cache");
980 assert_eq!(cache_stats2.changed, 0, "second scan: nothing changed");
981 }
982}