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
218pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
220 let compiled = compile_rules(rules)?;
221 let ignores = build_glob_set(&config.scan.ignores)?;
222
223 let files: Vec<(PathBuf, SupportLang)> = paths
224 .iter()
225 .flat_map(|path| WalkBuilder::new(path).build().flatten())
226 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
227 .map(|entry| entry.into_path())
228 .filter(|path| !ignores.is_match(path))
229 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
230 .collect();
231
232 let mut findings: Vec<Finding> = files
233 .par_iter()
234 .flat_map_iter(|(path, lang)| scan_file(path, *lang, &compiled))
235 .collect();
236 findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
237 findings.dedup_by(|a, b| {
238 a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
239 });
240
241 let (errors, warnings) =
242 findings
243 .iter()
244 .fold((0, 0), |(errors, warnings), f| match f.severity {
245 Severity::Error => (errors + 1, warnings),
246 Severity::Warning => (errors, warnings + 1),
247 });
248
249 Ok(ScanResult {
250 findings,
251 stats: ScanStats {
252 errors,
253 warnings,
254 total: errors + warnings,
255 files_scanned: files.len(),
256 baseline_filtered: 0,
257 },
258 cache_stats: None,
259 })
260}
261
262pub fn scan_cached(
271 paths: &[PathBuf],
272 rules: &[Rule],
273 config: &Config,
274 cache_dir: &Path,
275) -> Result<ScanResult, ScanError> {
276 let compiled = compile_rules(rules)?;
277 let ignores = build_glob_set(&config.scan.ignores)?;
278
279 let files: Vec<(PathBuf, SupportLang)> = paths
280 .iter()
281 .flat_map(|path| WalkBuilder::new(path).build().flatten())
282 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
283 .map(|entry| entry.into_path())
284 .filter(|path| !ignores.is_match(path))
285 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
286 .collect();
287
288 let store = CacheStore::with_dir(cache_dir.to_path_buf());
289 let current_rules_hash = rules_hash(rules);
290 let rules_changed = store
291 .check_rules_changed(¤t_rules_hash)
292 .unwrap_or(true);
293
294 let file_contents: Vec<(PathBuf, SupportLang, Vec<u8>, String)> = files
295 .into_iter()
296 .filter_map(|(path, lang)| {
297 let content = fs::read(&path).ok()?;
298 let hash = file_content_hash(&content);
299 Some((path, lang, content, hash))
300 })
301 .collect();
302
303 let mut cached_count = 0usize;
304 let mut changed_count = 0usize;
305 let mut all_findings: Vec<Finding> = Vec::new();
306
307 struct FileWork {
308 path: PathBuf,
309 lang: SupportLang,
310 hash: String,
311 }
312
313 let mut to_scan: Vec<FileWork> = Vec::new();
314
315 for (path, lang, _content, hash) in file_contents.iter() {
316 if !rules_changed {
317 if let Some(cached_findings) = store.get(hash) {
318 cached_count += 1;
319 all_findings.extend(cached_findings);
320 continue;
321 }
322 }
323 changed_count += 1;
324 to_scan.push(FileWork {
325 path: path.clone(),
326 lang: *lang,
327 hash: hash.clone(),
328 });
329 }
330
331 let scanned_findings: Vec<(String, Vec<Finding>)> = to_scan
332 .par_iter()
333 .map(|work| {
334 let findings = scan_file(&work.path, work.lang, &compiled);
335 (work.hash.clone(), findings)
336 })
337 .collect();
338
339 for (hash, findings) in scanned_findings {
340 store.put(&hash, &findings).ok();
341 all_findings.extend(findings);
342 }
343
344 let current_hashes: Vec<String> = file_contents.iter().map(|(_, _, _, h)| h.clone()).collect();
345 store.cleanup(¤t_hashes).ok();
346
347 all_findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
348 all_findings.dedup_by(|a, b| {
349 a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
350 });
351
352 let (errors, warnings) =
353 all_findings
354 .iter()
355 .fold((0, 0), |(errors, warnings), f| match f.severity {
356 Severity::Error => (errors + 1, warnings),
357 Severity::Warning => (errors, warnings + 1),
358 });
359
360 let files_scanned = cached_count + changed_count;
361
362 Ok(ScanResult {
363 findings: all_findings,
364 stats: ScanStats {
365 errors,
366 warnings,
367 total: errors + warnings,
368 files_scanned,
369 baseline_filtered: 0,
370 },
371 cache_stats: Some(CacheStats {
372 cached: cached_count,
373 changed: changed_count,
374 }),
375 })
376}
377
378#[cfg(test)]
379mod tests {
380 use std::fs::{create_dir, write};
381
382 use tempfile::tempdir;
383
384 use super::*;
385 use crate::rule::{load_builtin_rules, parse_rule, RuleId};
386
387 fn unwrap_rule() -> Rule {
388 parse_rule(
389 r#"
390id: test-unwrap
391language: rust
392severity: error
393category: correctness
394message: ".unwrap() forbidden"
395rule:
396 kind: call_expression
397 regex: '\.unwrap\(\)\s*$'
398"#,
399 )
400 .unwrap()
401 }
402
403 fn scan_dir(dir: &Path, rules: &[Rule]) -> ScanResult {
404 scan(&[dir.to_path_buf()], rules, &Config::default()).unwrap()
405 }
406
407 #[test]
408 fn scan_finds_unwrap_violation() {
409 let dir = tempdir().unwrap();
410 write(
411 dir.path().join("main.rs"),
412 "fn main() {\n foo().unwrap();\n}\n",
413 )
414 .unwrap();
415 let result = scan_dir(dir.path(), &[unwrap_rule()]);
416 assert_eq!(result.findings.len(), 1);
417 let f = &result.findings[0];
418 assert_eq!(f.rule_id, RuleId::from("test-unwrap"));
419 assert_eq!(f.severity, Severity::Error);
420 assert_eq!(f.line, 2);
421 assert!(f.matched_text.contains("unwrap()"));
422 }
423
424 #[test]
425 fn scan_clean_file_no_findings() {
426 let dir = tempdir().unwrap();
427 write(dir.path().join("main.rs"), "fn main() { let x = foo(); }\n").unwrap();
428 let result = scan_dir(dir.path(), &[unwrap_rule()]);
429 assert_eq!(result.findings.len(), 0);
430 assert_eq!(result.stats.files_scanned, 1);
431 }
432
433 #[test]
434 fn scan_unsupported_language_ignored() {
435 let dir = tempdir().unwrap();
436 write(dir.path().join("script.py"), "x.unwrap()\n").unwrap();
437 write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
438 let result = scan_dir(dir.path(), &[unwrap_rule()]);
439 assert_eq!(result.findings.len(), 0);
440 assert_eq!(result.stats.files_scanned, 1);
441 }
442
443 #[test]
444 fn scan_explicit_file_path() {
445 let dir = tempdir().unwrap();
446 let file = dir.path().join("main.rs");
447 write(&file, "fn main() { foo().unwrap(); }\n").unwrap();
448 let result = scan(&[file], &[unwrap_rule()], &Config::default()).unwrap();
449 assert_eq!(result.findings.len(), 1);
450 assert_eq!(result.stats.files_scanned, 1);
451 }
452
453 #[test]
454 fn rule_ignores_glob_respected() {
455 let dir = tempdir().unwrap();
456 let tests_dir = dir.path().join("tests");
457 create_dir(&tests_dir).unwrap();
458 write(
459 tests_dir.join("test_main.rs"),
460 "fn t() { foo().unwrap(); }\n",
461 )
462 .unwrap();
463 write(
464 dir.path().join("main.rs"),
465 "fn main() { foo().unwrap(); }\n",
466 )
467 .unwrap();
468 let rule = parse_rule(
469 r#"
470id: ign
471language: rust
472severity: error
473category: correctness
474message: x
475rule:
476 kind: call_expression
477 regex: '\.unwrap\(\)\s*$'
478ignores:
479 - "**/tests/**"
480"#,
481 )
482 .unwrap();
483 let result = scan_dir(dir.path(), &[rule]);
484 assert_eq!(result.findings.len(), 1);
485 assert!(result.findings[0]
486 .file
487 .to_string_lossy()
488 .contains("main.rs"));
489 }
490
491 #[test]
492 fn config_ignores_respected() {
493 let dir = tempdir().unwrap();
494 let gen = dir.path().join("generated");
495 create_dir(&gen).unwrap();
496 write(gen.join("out.rs"), "fn g() { foo().unwrap(); }\n").unwrap();
497 write(
498 dir.path().join("main.rs"),
499 "fn main() { foo().unwrap(); }\n",
500 )
501 .unwrap();
502 let mut cfg = Config::default();
503 cfg.scan.ignores = vec!["**/generated/**".to_string()];
504 let result = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap();
505 assert_eq!(result.stats.files_scanned, 1);
506 assert_eq!(result.findings.len(), 1);
507 }
508
509 #[test]
510 fn config_invalid_glob_is_an_error() {
511 let dir = tempdir().unwrap();
512 let mut cfg = Config::default();
513 cfg.scan.ignores = vec!["[".to_string()];
514 let err = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap_err();
515 assert!(matches!(err, ScanError::Glob(_)), "{err:?}");
516 }
517
518 #[test]
519 fn rule_without_kind_fails_to_compile() {
520 let dir = tempdir().unwrap();
521 let rule = parse_rule(
522 r#"
523id: no-kind
524language: rust
525severity: error
526category: correctness
527message: x
528rule:
529 regex: 'unwrap'
530"#,
531 )
532 .unwrap();
533 let err = scan(&[dir.path().to_path_buf()], &[rule], &Config::default()).unwrap_err();
534 assert!(
535 matches!(&err, ScanError::RuleCompile { id, .. } if *id == RuleId::from("no-kind")),
536 "{err:?}"
537 );
538 }
539
540 #[test]
541 fn stats_counting_accurate() {
542 let dir = tempdir().unwrap();
543 write(
544 dir.path().join("a.rs"),
545 "fn f() { foo().unwrap(); bar().unwrap(); }\n",
546 )
547 .unwrap();
548 let result = scan_dir(dir.path(), &[unwrap_rule()]);
549 assert_eq!(result.stats.errors, 2);
550 assert_eq!(result.stats.warnings, 0);
551 assert_eq!(result.stats.total, 2);
552 assert_eq!(result.stats.files_scanned, 1);
553 }
554
555 #[test]
556 fn scan_empty_file() {
557 let dir = tempdir().unwrap();
558 write(dir.path().join("empty.rs"), "").unwrap();
559 let result = scan_dir(dir.path(), &[unwrap_rule()]);
560 assert_eq!(result.findings.len(), 0);
561 assert_eq!(result.stats.files_scanned, 1);
562 }
563
564 #[test]
565 fn scan_complex_rule() {
566 let rule = parse_rule(
567 r#"
568id: dbg
569language: rust
570severity: error
571category: security
572message: debug on secret
573rule:
574 kind: attribute_item
575 all:
576 - regex: 'derive\([^)]*Debug'
577 - precedes:
578 kind: struct_item
579 has:
580 kind: field_identifier
581 regex: '^(api_key|password|secret)$'
582 stopBy: end
583"#,
584 )
585 .unwrap();
586 let dir = tempdir().unwrap();
587 write(
588 dir.path().join("t.rs"),
589 "#[derive(Debug)]\nstruct Config {\n api_key: String,\n}\n",
590 )
591 .unwrap();
592 let result = scan_dir(dir.path(), &[rule]);
593 assert_eq!(result.findings.len(), 1);
594 assert_eq!(result.findings[0].rule_id, RuleId::from("dbg"));
595 }
596
597 #[test]
598 fn typescript_rule_applies_to_tsx_files() {
599 let rule = parse_rule(
600 r#"
601id: ts-any
602language: typescript
603severity: warning
604category: slop
605message: no any
606rule:
607 kind: predefined_type
608 regex: '^any$'
609"#,
610 )
611 .unwrap();
612 let dir = tempdir().unwrap();
613 write(dir.path().join("a.ts"), "let x: any = 1;\n").unwrap();
614 write(dir.path().join("b.tsx"), "let y: any = 2;\n").unwrap();
615 let result = scan_dir(dir.path(), &[rule]);
616 assert_eq!(result.stats.files_scanned, 2);
617 assert_eq!(result.stats.warnings, 2);
618 }
619
620 #[test]
621 fn scan_with_disable_comment_filters_findings() {
622 let dir = tempdir().unwrap();
623 write(
624 dir.path().join("main.rs"),
625 "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n bar().unwrap();\n}\n",
626 )
627 .unwrap();
628 let result = scan_dir(dir.path(), &[unwrap_rule()]);
629 assert_eq!(
630 result.findings.len(),
631 1,
632 "only the non-disabled unwrap should remain"
633 );
634 assert_eq!(result.findings[0].line, 4);
635 }
636
637 #[test]
638 fn scan_with_disable_rule_id_filters_only_that_rule() {
639 let dir = tempdir().unwrap();
640 write(
641 dir.path().join("main.rs"),
642 "fn main() {\n // slopguard-disable-next-line test-unwrap\n foo().unwrap();\n}\n",
643 )
644 .unwrap();
645 let result = scan_dir(dir.path(), &[unwrap_rule()]);
646 assert_eq!(result.findings.len(), 0);
647 }
648
649 fn unwrap_rule_skipping_test_code() -> Rule {
650 parse_rule(
651 r#"
652id: test-unwrap
653language: rust
654severity: error
655category: correctness
656message: ".unwrap() forbidden"
657rule:
658 kind: call_expression
659 regex: '\.unwrap\(\)\s*$'
660skip_test_code: true
661"#,
662 )
663 .unwrap()
664 }
665
666 #[test]
667 fn cfg_test_block_excluded_by_post_filter() {
668 let dir = tempdir().unwrap();
669 write(
670 dir.path().join("main.rs"),
671 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
672 )
673 .unwrap();
674 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
675 assert_eq!(
676 result.findings.len(),
677 1,
678 "should only find the prod unwrap, not the one inside #[cfg(test)]"
679 );
680 assert_eq!(result.findings[0].line, 2);
681 }
682
683 #[test]
684 fn cfg_test_kept_without_skip_test_code() {
685 let dir = tempdir().unwrap();
686 write(
687 dir.path().join("main.rs"),
688 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
689 )
690 .unwrap();
691 let result = scan_dir(dir.path(), &[unwrap_rule()]);
692 assert_eq!(
693 result.findings.len(),
694 2,
695 "rule without skip_test_code should find both"
696 );
697 }
698
699 #[test]
700 fn integration_test_file_excluded_by_skip_test_code() {
701 let dir = tempdir().unwrap();
702 let tests_dir = dir.path().join("tests");
703 std::fs::create_dir(&tests_dir).unwrap();
704 write(
705 tests_dir.join("it.rs"),
706 "fn check() {\n foo().unwrap();\n}\n",
707 )
708 .unwrap();
709 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
710 assert_eq!(
711 result.findings.len(),
712 0,
713 "unwrap inside a tests/ integration file should be skipped"
714 );
715 }
716
717 #[test]
718 fn integration_test_file_kept_without_skip_test_code() {
719 let dir = tempdir().unwrap();
720 let tests_dir = dir.path().join("tests");
721 std::fs::create_dir(&tests_dir).unwrap();
722 write(
723 tests_dir.join("it.rs"),
724 "fn check() {\n foo().unwrap();\n}\n",
725 )
726 .unwrap();
727 let result = scan_dir(dir.path(), &[unwrap_rule()]);
728 assert_eq!(
729 result.findings.len(),
730 1,
731 "rule without skip_test_code should still flag tests/ files"
732 );
733 }
734
735 #[test]
736 fn test_crate_directory_excluded_by_skip_test_code() {
737 let dir = tempdir().unwrap();
738 let test_crate = dir
739 .path()
740 .join("crates")
741 .join("integrations_tests")
742 .join("src");
743 std::fs::create_dir_all(&test_crate).unwrap();
744 write(
745 test_crate.join("api.rs"),
746 "fn check() {\n foo().unwrap();\n}\n",
747 )
748 .unwrap();
749 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
750 assert_eq!(
751 result.findings.len(),
752 0,
753 "unwrap inside a *_tests crate should be skipped"
754 );
755 }
756
757 #[test]
758 fn test_crate_directory_kept_without_skip_test_code() {
759 let dir = tempdir().unwrap();
760 let test_crate = dir
761 .path()
762 .join("crates")
763 .join("integrations_tests")
764 .join("src");
765 std::fs::create_dir_all(&test_crate).unwrap();
766 write(
767 test_crate.join("api.rs"),
768 "fn check() {\n foo().unwrap();\n}\n",
769 )
770 .unwrap();
771 let result = scan_dir(dir.path(), &[unwrap_rule()]);
772 assert_eq!(
773 result.findings.len(),
774 1,
775 "rule without skip_test_code should still flag *_tests crate files"
776 );
777 }
778
779 #[test]
780 fn all_builtin_rules_compile() {
781 let rules = load_builtin_rules().unwrap();
782 assert!(
783 rules.len() >= 55,
784 "expected at least 55 rules, got {}",
785 rules.len()
786 );
787 let compiled = compile_rules(&rules).expect("all builtin rules should compile");
788 assert_eq!(compiled.by_id.len(), rules.len());
789 }
790
791 #[test]
792 fn scan_with_cache() {
793 let src_dir = tempdir().unwrap();
794 let cache_dir = tempdir().unwrap();
795 let cache_path = cache_dir.path().join("cache");
796 write(
797 src_dir.path().join("main.rs"),
798 "fn main() {\n foo().unwrap();\n}\n",
799 )
800 .unwrap();
801
802 let rules = [unwrap_rule()];
803 let config = Config::default();
804
805 let result1 = scan_cached(
806 &[src_dir.path().to_path_buf()],
807 &rules,
808 &config,
809 &cache_path,
810 )
811 .unwrap();
812
813 assert_eq!(result1.findings.len(), 1);
814 let cache_stats1 = result1.cache_stats.as_ref().unwrap();
815 assert_eq!(cache_stats1.cached, 0, "first scan: nothing cached");
816 assert_eq!(cache_stats1.changed, 1, "first scan: one file scanned");
817
818 let result2 = scan_cached(
819 &[src_dir.path().to_path_buf()],
820 &rules,
821 &config,
822 &cache_path,
823 )
824 .unwrap();
825
826 assert_eq!(result2.findings.len(), 1);
827 let cache_stats2 = result2.cache_stats.as_ref().unwrap();
828 assert_eq!(cache_stats2.cached, 1, "second scan: one file from cache");
829 assert_eq!(cache_stats2.changed, 0, "second scan: nothing changed");
830 }
831}