Skip to main content

slopguard_core/
scanner.rs

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/// The subset of a slopguard rule that ast-grep understands. slopguard-only
41/// fields (`category`, `fix` as free text, `tests`) are deliberately left out.
42#[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
57/// Rules compiled for scanning, plus a way back to the slopguard rule that
58/// produced each ast-grep config.
59struct 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
129/// Whether `path` lives in a conventional test, bench, or example tree.
130///
131/// Rust integration tests, benchmarks, and examples are not `#[cfg(test)]`
132/// modules, so `CfgTestRanges` cannot see them. Rules with `skip_test_code`
133/// should treat these whole files as test code.
134fn 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
211/// Scan the given paths for rule violations.
212pub 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
254/// Scan with file-level caching. Files whose content hash matches a cached
255/// entry (and whose rules have not changed) return cached findings without
256/// reparsing.
257///
258/// `cache_dir` is the directory where cache files are stored. When a custom
259/// cache directory is specified (via `--cache-dir`, `SLOPGUARD_CACHE_DIR`,
260/// or `scan.cache_dir` in config), pass it directly. Otherwise pass the
261/// project root and use `CacheStore::new` which appends `.slopguard-cache`.
262pub 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(&current_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(&current_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}