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