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 escalated: false,
203 }
204 })
205 .filter(move |f| {
206 if !skip_test_code {
207 return true;
208 }
209 let in_test_code =
210 is_test_file || cfg_test.is_some_and(|r| r.contains_line(f.line));
211 !in_test_code
212 })
213 })
214 .collect();
215
216 filter_disabled(findings, &source)
217}
218
219fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
221 paths
222 .iter()
223 .flat_map(|path| WalkBuilder::new(path).build().flatten())
224 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
225 .map(|entry| entry.into_path())
226 .filter(|path| !ignores.is_match(path))
227 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
228 .collect()
229}
230
231fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
237 files
238 .iter()
239 .filter(|path| path.is_file())
240 .filter(|path| !ignores.is_match(path))
241 .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
242 .collect()
243}
244
245pub fn count_severities(findings: &[Finding]) -> (usize, usize) {
247 let mut errors = 0;
248 let mut warnings = 0;
249 for finding in findings {
250 match finding.severity {
251 Severity::Error => errors += 1,
252 Severity::Warning => warnings += 1,
253 }
254 }
255 (errors, warnings)
256}
257
258fn normalize_findings(findings: &mut Vec<Finding>) {
260 findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
261 findings.dedup_by(|a, b| {
262 a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
263 });
264}
265
266fn scan_collected(files: Vec<(PathBuf, SupportLang)>, compiled: &CompiledRules) -> ScanResult {
268 let mut findings: Vec<Finding> = files
269 .par_iter()
270 .flat_map_iter(|(path, lang)| scan_file(path, *lang, compiled))
271 .collect();
272 normalize_findings(&mut findings);
273
274 let (errors, warnings) = count_severities(&findings);
275
276 ScanResult {
277 findings,
278 stats: ScanStats {
279 errors,
280 warnings,
281 total: errors + warnings,
282 files_scanned: files.len(),
283 baseline_filtered: 0,
284 diff_base: None,
285 files_changed: None,
286 },
287 cache_stats: None,
288 }
289}
290
291fn scan_collected_cached(
298 files: Vec<(PathBuf, SupportLang)>,
299 compiled: &CompiledRules,
300 rules: &[Rule],
301 cache_dir: &Path,
302 prune: bool,
303) -> ScanResult {
304 let store = CacheStore::with_dir(cache_dir.to_path_buf());
305 let current_rules_hash = rules_hash(rules);
306 let rules_changed = store
307 .check_rules_changed(¤t_rules_hash)
308 .unwrap_or(true);
309
310 let file_contents: Vec<(PathBuf, SupportLang, Vec<u8>, String)> = files
311 .into_iter()
312 .filter_map(|(path, lang)| {
313 let content = fs::read(&path).ok()?;
314 let hash = file_content_hash(&content);
315 Some((path, lang, content, hash))
316 })
317 .collect();
318
319 let mut cached_count = 0usize;
320 let mut changed_count = 0usize;
321 let mut all_findings: Vec<Finding> = Vec::new();
322
323 struct FileWork {
324 path: PathBuf,
325 lang: SupportLang,
326 hash: String,
327 }
328
329 let mut to_scan: Vec<FileWork> = Vec::new();
330
331 for (path, lang, _content, hash) in file_contents.iter() {
332 if !rules_changed {
333 if let Some(cached_findings) = store.get(hash) {
334 cached_count += 1;
335 all_findings.extend(cached_findings);
336 continue;
337 }
338 }
339 changed_count += 1;
340 to_scan.push(FileWork {
341 path: path.clone(),
342 lang: *lang,
343 hash: hash.clone(),
344 });
345 }
346
347 let scanned_findings: Vec<(String, Vec<Finding>)> = to_scan
348 .par_iter()
349 .map(|work| {
350 let findings = scan_file(&work.path, work.lang, compiled);
351 (work.hash.clone(), findings)
352 })
353 .collect();
354
355 for (hash, findings) in scanned_findings {
356 store.put(&hash, &findings).ok();
357 all_findings.extend(findings);
358 }
359
360 if prune {
361 let current_hashes: Vec<String> =
362 file_contents.iter().map(|(_, _, _, h)| h.clone()).collect();
363 store.cleanup(¤t_hashes).ok();
364 }
365
366 normalize_findings(&mut all_findings);
367
368 let (errors, warnings) = count_severities(&all_findings);
369
370 let files_scanned = cached_count + changed_count;
371
372 ScanResult {
373 findings: all_findings,
374 stats: ScanStats {
375 errors,
376 warnings,
377 total: errors + warnings,
378 files_scanned,
379 baseline_filtered: 0,
380 diff_base: None,
381 files_changed: None,
382 },
383 cache_stats: Some(CacheStats {
384 cached: cached_count,
385 changed: changed_count,
386 }),
387 }
388}
389
390pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
392 let compiled = compile_rules(rules)?;
393 let ignores = build_glob_set(&config.scan.ignores)?;
394 Ok(scan_collected(walk_files(paths, &ignores), &compiled))
395}
396
397pub fn scan_cached(
406 paths: &[PathBuf],
407 rules: &[Rule],
408 config: &Config,
409 cache_dir: &Path,
410) -> Result<ScanResult, ScanError> {
411 let compiled = compile_rules(rules)?;
412 let ignores = build_glob_set(&config.scan.ignores)?;
413 Ok(scan_collected_cached(
414 walk_files(paths, &ignores),
415 &compiled,
416 rules,
417 cache_dir,
418 true,
419 ))
420}
421
422pub fn scan_files(
427 files: &[PathBuf],
428 rules: &[Rule],
429 config: &Config,
430) -> Result<ScanResult, ScanError> {
431 let compiled = compile_rules(rules)?;
432 let ignores = build_glob_set(&config.scan.ignores)?;
433 Ok(scan_collected(explicit_files(files, &ignores), &compiled))
434}
435
436pub fn scan_files_cached(
439 files: &[PathBuf],
440 rules: &[Rule],
441 config: &Config,
442 cache_dir: &Path,
443) -> Result<ScanResult, ScanError> {
444 let compiled = compile_rules(rules)?;
445 let ignores = build_glob_set(&config.scan.ignores)?;
446 Ok(scan_collected_cached(
447 explicit_files(files, &ignores),
448 &compiled,
449 rules,
450 cache_dir,
451 false,
452 ))
453}
454
455#[cfg(test)]
456mod tests {
457 use std::fs::{create_dir, write};
458
459 use tempfile::tempdir;
460
461 use super::*;
462 use crate::rule::{load_builtin_rules, parse_rule, RuleId};
463
464 fn unwrap_rule() -> Rule {
465 parse_rule(
466 r#"
467id: test-unwrap
468language: rust
469severity: error
470category: correctness
471message: ".unwrap() forbidden"
472rule:
473 kind: call_expression
474 regex: '\.unwrap\(\)\s*$'
475"#,
476 )
477 .unwrap()
478 }
479
480 fn scan_dir(dir: &Path, rules: &[Rule]) -> ScanResult {
481 scan(&[dir.to_path_buf()], rules, &Config::default()).unwrap()
482 }
483
484 #[test]
485 fn scan_finds_unwrap_violation() {
486 let dir = tempdir().unwrap();
487 write(
488 dir.path().join("main.rs"),
489 "fn main() {\n foo().unwrap();\n}\n",
490 )
491 .unwrap();
492 let result = scan_dir(dir.path(), &[unwrap_rule()]);
493 assert_eq!(result.findings.len(), 1);
494 let f = &result.findings[0];
495 assert_eq!(f.rule_id, RuleId::from("test-unwrap"));
496 assert_eq!(f.severity, Severity::Error);
497 assert_eq!(f.line, 2);
498 assert!(f.matched_text.contains("unwrap()"));
499 }
500
501 #[test]
502 fn scan_clean_file_no_findings() {
503 let dir = tempdir().unwrap();
504 write(dir.path().join("main.rs"), "fn main() { let x = foo(); }\n").unwrap();
505 let result = scan_dir(dir.path(), &[unwrap_rule()]);
506 assert_eq!(result.findings.len(), 0);
507 assert_eq!(result.stats.files_scanned, 1);
508 }
509
510 #[test]
511 fn scan_unsupported_language_ignored() {
512 let dir = tempdir().unwrap();
513 write(dir.path().join("script.py"), "x.unwrap()\n").unwrap();
514 write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
515 let result = scan_dir(dir.path(), &[unwrap_rule()]);
516 assert_eq!(result.findings.len(), 0);
517 assert_eq!(result.stats.files_scanned, 1);
518 }
519
520 #[test]
521 fn scan_explicit_file_path() {
522 let dir = tempdir().unwrap();
523 let file = dir.path().join("main.rs");
524 write(&file, "fn main() { foo().unwrap(); }\n").unwrap();
525 let result = scan(&[file], &[unwrap_rule()], &Config::default()).unwrap();
526 assert_eq!(result.findings.len(), 1);
527 assert_eq!(result.stats.files_scanned, 1);
528 }
529
530 #[test]
531 fn rule_ignores_glob_respected() {
532 let dir = tempdir().unwrap();
533 let tests_dir = dir.path().join("tests");
534 create_dir(&tests_dir).unwrap();
535 write(
536 tests_dir.join("test_main.rs"),
537 "fn t() { foo().unwrap(); }\n",
538 )
539 .unwrap();
540 write(
541 dir.path().join("main.rs"),
542 "fn main() { foo().unwrap(); }\n",
543 )
544 .unwrap();
545 let rule = parse_rule(
546 r#"
547id: ign
548language: rust
549severity: error
550category: correctness
551message: x
552rule:
553 kind: call_expression
554 regex: '\.unwrap\(\)\s*$'
555ignores:
556 - "**/tests/**"
557"#,
558 )
559 .unwrap();
560 let result = scan_dir(dir.path(), &[rule]);
561 assert_eq!(result.findings.len(), 1);
562 assert!(result.findings[0]
563 .file
564 .to_string_lossy()
565 .contains("main.rs"));
566 }
567
568 #[test]
569 fn config_ignores_respected() {
570 let dir = tempdir().unwrap();
571 let gen = dir.path().join("generated");
572 create_dir(&gen).unwrap();
573 write(gen.join("out.rs"), "fn g() { foo().unwrap(); }\n").unwrap();
574 write(
575 dir.path().join("main.rs"),
576 "fn main() { foo().unwrap(); }\n",
577 )
578 .unwrap();
579 let mut cfg = Config::default();
580 cfg.scan.ignores = vec!["**/generated/**".to_string()];
581 let result = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap();
582 assert_eq!(result.stats.files_scanned, 1);
583 assert_eq!(result.findings.len(), 1);
584 }
585
586 #[test]
587 fn config_invalid_glob_is_an_error() {
588 let dir = tempdir().unwrap();
589 let mut cfg = Config::default();
590 cfg.scan.ignores = vec!["[".to_string()];
591 let err = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap_err();
592 assert!(matches!(err, ScanError::Glob(_)), "{err:?}");
593 }
594
595 #[test]
596 fn rule_without_kind_fails_to_compile() {
597 let dir = tempdir().unwrap();
598 let rule = parse_rule(
599 r#"
600id: no-kind
601language: rust
602severity: error
603category: correctness
604message: x
605rule:
606 regex: 'unwrap'
607"#,
608 )
609 .unwrap();
610 let err = scan(&[dir.path().to_path_buf()], &[rule], &Config::default()).unwrap_err();
611 assert!(
612 matches!(&err, ScanError::RuleCompile { id, .. } if *id == RuleId::from("no-kind")),
613 "{err:?}"
614 );
615 }
616
617 #[test]
618 fn stats_counting_accurate() {
619 let dir = tempdir().unwrap();
620 write(
621 dir.path().join("a.rs"),
622 "fn f() { foo().unwrap(); bar().unwrap(); }\n",
623 )
624 .unwrap();
625 let result = scan_dir(dir.path(), &[unwrap_rule()]);
626 assert_eq!(result.stats.errors, 2);
627 assert_eq!(result.stats.warnings, 0);
628 assert_eq!(result.stats.total, 2);
629 assert_eq!(result.stats.files_scanned, 1);
630 }
631
632 #[test]
633 fn scan_empty_file() {
634 let dir = tempdir().unwrap();
635 write(dir.path().join("empty.rs"), "").unwrap();
636 let result = scan_dir(dir.path(), &[unwrap_rule()]);
637 assert_eq!(result.findings.len(), 0);
638 assert_eq!(result.stats.files_scanned, 1);
639 }
640
641 #[test]
642 fn scan_complex_rule() {
643 let rule = parse_rule(
644 r#"
645id: dbg
646language: rust
647severity: error
648category: security
649message: debug on secret
650rule:
651 kind: attribute_item
652 all:
653 - regex: 'derive\([^)]*Debug'
654 - precedes:
655 kind: struct_item
656 has:
657 kind: field_identifier
658 regex: '^(api_key|password|secret)$'
659 stopBy: end
660"#,
661 )
662 .unwrap();
663 let dir = tempdir().unwrap();
664 write(
665 dir.path().join("t.rs"),
666 "#[derive(Debug)]\nstruct Config {\n api_key: String,\n}\n",
667 )
668 .unwrap();
669 let result = scan_dir(dir.path(), &[rule]);
670 assert_eq!(result.findings.len(), 1);
671 assert_eq!(result.findings[0].rule_id, RuleId::from("dbg"));
672 }
673
674 #[test]
675 fn typescript_rule_applies_to_tsx_files() {
676 let rule = parse_rule(
677 r#"
678id: ts-any
679language: typescript
680severity: warning
681category: slop
682message: no any
683rule:
684 kind: predefined_type
685 regex: '^any$'
686"#,
687 )
688 .unwrap();
689 let dir = tempdir().unwrap();
690 write(dir.path().join("a.ts"), "let x: any = 1;\n").unwrap();
691 write(dir.path().join("b.tsx"), "let y: any = 2;\n").unwrap();
692 let result = scan_dir(dir.path(), &[rule]);
693 assert_eq!(result.stats.files_scanned, 2);
694 assert_eq!(result.stats.warnings, 2);
695 }
696
697 #[test]
698 fn scan_with_disable_comment_filters_findings() {
699 let dir = tempdir().unwrap();
700 write(
701 dir.path().join("main.rs"),
702 "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n bar().unwrap();\n}\n",
703 )
704 .unwrap();
705 let result = scan_dir(dir.path(), &[unwrap_rule()]);
706 assert_eq!(
707 result.findings.len(),
708 1,
709 "only the non-disabled unwrap should remain"
710 );
711 assert_eq!(result.findings[0].line, 4);
712 }
713
714 #[test]
715 fn scan_with_disable_rule_id_filters_only_that_rule() {
716 let dir = tempdir().unwrap();
717 write(
718 dir.path().join("main.rs"),
719 "fn main() {\n // slopguard-disable-next-line test-unwrap\n foo().unwrap();\n}\n",
720 )
721 .unwrap();
722 let result = scan_dir(dir.path(), &[unwrap_rule()]);
723 assert_eq!(result.findings.len(), 0);
724 }
725
726 fn unwrap_rule_skipping_test_code() -> Rule {
727 parse_rule(
728 r#"
729id: test-unwrap
730language: rust
731severity: error
732category: correctness
733message: ".unwrap() forbidden"
734rule:
735 kind: call_expression
736 regex: '\.unwrap\(\)\s*$'
737skip_test_code: true
738"#,
739 )
740 .unwrap()
741 }
742
743 #[test]
744 fn cfg_test_block_excluded_by_post_filter() {
745 let dir = tempdir().unwrap();
746 write(
747 dir.path().join("main.rs"),
748 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
749 )
750 .unwrap();
751 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
752 assert_eq!(
753 result.findings.len(),
754 1,
755 "should only find the prod unwrap, not the one inside #[cfg(test)]"
756 );
757 assert_eq!(result.findings[0].line, 2);
758 }
759
760 #[test]
761 fn cfg_test_kept_without_skip_test_code() {
762 let dir = tempdir().unwrap();
763 write(
764 dir.path().join("main.rs"),
765 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
766 )
767 .unwrap();
768 let result = scan_dir(dir.path(), &[unwrap_rule()]);
769 assert_eq!(
770 result.findings.len(),
771 2,
772 "rule without skip_test_code should find both"
773 );
774 }
775
776 #[test]
777 fn integration_test_file_excluded_by_skip_test_code() {
778 let dir = tempdir().unwrap();
779 let tests_dir = dir.path().join("tests");
780 std::fs::create_dir(&tests_dir).unwrap();
781 write(
782 tests_dir.join("it.rs"),
783 "fn check() {\n foo().unwrap();\n}\n",
784 )
785 .unwrap();
786 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
787 assert_eq!(
788 result.findings.len(),
789 0,
790 "unwrap inside a tests/ integration file should be skipped"
791 );
792 }
793
794 #[test]
795 fn integration_test_file_kept_without_skip_test_code() {
796 let dir = tempdir().unwrap();
797 let tests_dir = dir.path().join("tests");
798 std::fs::create_dir(&tests_dir).unwrap();
799 write(
800 tests_dir.join("it.rs"),
801 "fn check() {\n foo().unwrap();\n}\n",
802 )
803 .unwrap();
804 let result = scan_dir(dir.path(), &[unwrap_rule()]);
805 assert_eq!(
806 result.findings.len(),
807 1,
808 "rule without skip_test_code should still flag tests/ files"
809 );
810 }
811
812 #[test]
813 fn test_crate_directory_excluded_by_skip_test_code() {
814 let dir = tempdir().unwrap();
815 let test_crate = dir
816 .path()
817 .join("crates")
818 .join("integrations_tests")
819 .join("src");
820 std::fs::create_dir_all(&test_crate).unwrap();
821 write(
822 test_crate.join("api.rs"),
823 "fn check() {\n foo().unwrap();\n}\n",
824 )
825 .unwrap();
826 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
827 assert_eq!(
828 result.findings.len(),
829 0,
830 "unwrap inside a *_tests crate should be skipped"
831 );
832 }
833
834 #[test]
835 fn test_crate_directory_kept_without_skip_test_code() {
836 let dir = tempdir().unwrap();
837 let test_crate = dir
838 .path()
839 .join("crates")
840 .join("integrations_tests")
841 .join("src");
842 std::fs::create_dir_all(&test_crate).unwrap();
843 write(
844 test_crate.join("api.rs"),
845 "fn check() {\n foo().unwrap();\n}\n",
846 )
847 .unwrap();
848 let result = scan_dir(dir.path(), &[unwrap_rule()]);
849 assert_eq!(
850 result.findings.len(),
851 1,
852 "rule without skip_test_code should still flag *_tests crate files"
853 );
854 }
855
856 #[test]
857 fn all_builtin_rules_compile() {
858 let rules = load_builtin_rules().unwrap();
859 assert!(
860 rules.len() >= 55,
861 "expected at least 55 rules, got {}",
862 rules.len()
863 );
864 let compiled = compile_rules(&rules).expect("all builtin rules should compile");
865 assert_eq!(compiled.by_id.len(), rules.len());
866 }
867
868 #[test]
869 fn scan_files_only_scans_given_files() {
870 let dir = tempdir().unwrap();
871 let a = dir.path().join("a.rs");
872 let b = dir.path().join("b.rs");
873 write(&a, "fn a() {\n foo().unwrap();\n}\n").unwrap();
874 write(&b, "fn b() {\n bar().unwrap();\n}\n").unwrap();
875
876 let result = scan_files(&[a], &[unwrap_rule()], &Config::default()).unwrap();
877 assert_eq!(result.findings.len(), 1);
878 assert_eq!(result.stats.files_scanned, 1);
879 assert!(result.findings[0].file.to_string_lossy().contains("a.rs"));
880 }
881
882 #[test]
883 fn scan_files_skips_unsupported_language() {
884 let dir = tempdir().unwrap();
885 let py = dir.path().join("script.py");
886 write(&py, "x.unwrap()\n").unwrap();
887
888 let result = scan_files(&[py], &[unwrap_rule()], &Config::default()).unwrap();
889 assert_eq!(result.findings.len(), 0);
890 assert_eq!(result.stats.files_scanned, 0);
891 }
892
893 #[test]
894 fn scan_files_skips_missing_file() {
895 let dir = tempdir().unwrap();
896 let missing = dir.path().join("gone.rs");
897
898 let result = scan_files(&[missing], &[unwrap_rule()], &Config::default()).unwrap();
899 assert_eq!(result.findings.len(), 0);
900 assert_eq!(result.stats.files_scanned, 0);
901 }
902
903 #[test]
904 fn scan_files_respects_config_ignores() {
905 let dir = tempdir().unwrap();
906 let gen = dir.path().join("generated");
907 create_dir(&gen).unwrap();
908 let out = gen.join("out.rs");
909 write(&out, "fn g() { foo().unwrap(); }\n").unwrap();
910
911 let mut cfg = Config::default();
912 cfg.scan.ignores = vec!["**/generated/**".to_string()];
913 let result = scan_files(&[out], &[unwrap_rule()], &cfg).unwrap();
914 assert_eq!(result.findings.len(), 0);
915 assert_eq!(result.stats.files_scanned, 0);
916 }
917
918 #[test]
919 fn scan_files_cached_keeps_other_entries() {
920 let src_dir = tempdir().unwrap();
921 let cache_dir = tempdir().unwrap();
922 let cache_path = cache_dir.path().join("cache");
923 let a = src_dir.path().join("a.rs");
924 let b = src_dir.path().join("b.rs");
925 write(&a, "fn a() {\n foo().unwrap();\n}\n").unwrap();
926 write(&b, "fn b() {\n bar().unwrap();\n}\n").unwrap();
927
928 let rules = [unwrap_rule()];
929 let config = Config::default();
930 let paths = [src_dir.path().to_path_buf()];
931
932 scan_cached(&paths, &rules, &config, &cache_path).unwrap();
933
934 scan_files_cached(&[a], &rules, &config, &cache_path).unwrap();
936
937 let result = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
938 let stats = result.cache_stats.as_ref().unwrap();
939 assert_eq!(stats.cached, 2, "a partial scan must not prune");
940 assert_eq!(stats.changed, 0);
941 }
942
943 #[test]
944 fn scan_with_cache() {
945 let src_dir = tempdir().unwrap();
946 let cache_dir = tempdir().unwrap();
947 let cache_path = cache_dir.path().join("cache");
948 write(
949 src_dir.path().join("main.rs"),
950 "fn main() {\n foo().unwrap();\n}\n",
951 )
952 .unwrap();
953
954 let rules = [unwrap_rule()];
955 let config = Config::default();
956
957 let result1 = scan_cached(
958 &[src_dir.path().to_path_buf()],
959 &rules,
960 &config,
961 &cache_path,
962 )
963 .unwrap();
964
965 assert_eq!(result1.findings.len(), 1);
966 let cache_stats1 = result1.cache_stats.as_ref().unwrap();
967 assert_eq!(cache_stats1.cached, 0, "first scan: nothing cached");
968 assert_eq!(cache_stats1.changed, 1, "first scan: one file scanned");
969
970 let result2 = scan_cached(
971 &[src_dir.path().to_path_buf()],
972 &rules,
973 &config,
974 &cache_path,
975 )
976 .unwrap();
977
978 assert_eq!(result2.findings.len(), 1);
979 let cache_stats2 = result2.cache_stats.as_ref().unwrap();
980 assert_eq!(cache_stats2.cached, 1, "second scan: one file from cache");
981 assert_eq!(cache_stats2.changed, 0, "second scan: nothing changed");
982 }
983}