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.
134///
135/// Matches exact directory names (`tests`, `benches`, `examples`) and also
136/// crate-level test directories whose name ends with `_test` or `_tests`
137/// (e.g. `integrations_tests`, `e2e_tests`).
138fn 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
218/// Scan the given paths for rule violations.
219pub 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
262/// Scan with file-level caching. Files whose content hash matches a cached
263/// entry (and whose rules have not changed) return cached findings without
264/// reparsing.
265///
266/// `cache_dir` is the directory where cache files are stored. When a custom
267/// cache directory is specified (via `--cache-dir`, `SLOPGUARD_CACHE_DIR`,
268/// or `scan.cache_dir` in config), pass it directly. Otherwise pass the
269/// project root and use `CacheStore::new` which appends `.slopguard-cache`.
270pub 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(&current_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(&current_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}