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