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