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_core::{AstGrep, Doc};
11use ast_grep_language::SupportLang;
12use globset::{Error as GlobError, Glob, GlobSet, GlobSetBuilder};
13use ignore::WalkBuilder;
14use rayon::prelude::*;
15use serde::Serialize;
16use serde_yaml::with::singleton_map_recursive;
17use serde_yaml::{to_value, Value};
18use strum::IntoEnumIterator;
19use thiserror::Error;
20
21use crate::cache::{file_content_hash, rules_hash, CacheStore};
22use crate::config::Config;
23use crate::disable::filter_disabled;
24use crate::finding::{CacheStats, Finding, ScanResult, ScanStats};
25use crate::metric::{self, Metric};
26use crate::rule::{Language, Rule, RuleId, Severity};
27use crate::test_filter::CfgTestRanges;
28
29#[derive(Debug, Error)]
30pub enum ScanError {
31    #[error("failed to compile rule '{id}': {source}")]
32    RuleCompile {
33        id: RuleId,
34        #[source]
35        source: RuleConfigError,
36    },
37
38    #[error("invalid glob pattern: {0}")]
39    Glob(#[from] GlobError),
40
41    #[error("rule '{id}': metric rule is missing a threshold")]
42    InvalidMetricRule { id: RuleId },
43}
44
45/// The subset of a slopguard rule that ast-grep understands. slopguard-only
46/// fields (`category`, `fix` as free text, `tests`) are deliberately left out.
47#[derive(Serialize)]
48pub struct AstGrepRule<'a> {
49    pub id: &'a str,
50    pub language: SupportLang,
51    pub severity: &'a Severity,
52    pub message: &'a str,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub note: Option<&'a str>,
55    pub rule: &'a Value,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub files: Option<&'a [String]>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub ignores: Option<&'a [String]>,
60}
61
62/// Rules compiled for scanning, plus a way back to the slopguard rule that
63/// produced each ast-grep config.
64struct CompiledRules<'a> {
65    collection: RuleCollection<SupportLang>,
66    by_id: HashMap<(&'a str, Language), &'a Rule>,
67    metrics: Vec<MetricRule<'a>>,
68}
69
70/// A metric rule prepared for scanning: threshold resolved and `files` /
71/// `ignores` globs compiled once. ast-grep applies those globs for AST rules;
72/// metric rules are evaluated outside ast-grep so they apply them here.
73struct MetricRule<'a> {
74    rule: &'a Rule,
75    metric: Metric,
76    threshold: f64,
77    files: Option<GlobSet>,
78    ignores: Option<GlobSet>,
79}
80
81impl MetricRule<'_> {
82    fn applies_to(&self, path: &Path, lang: &Language) -> bool {
83        self.rule.language == *lang
84            && self.files.as_ref().is_none_or(|g| g.is_match(path))
85            && !self.ignores.as_ref().is_some_and(|g| g.is_match(path))
86    }
87}
88
89fn extension_to_lang(path: &Path) -> Option<SupportLang> {
90    SupportLang::from_path(path)
91        .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
92}
93
94fn support_lang_to_language(lang: SupportLang) -> Language {
95    match lang {
96        SupportLang::Rust => Language::Rust,
97        _ => Language::TypeScript,
98    }
99}
100
101pub(crate) fn compile_ast_grep_rule(
102    rule: &AstGrepRule,
103) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
104    let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
105        .and_then(singleton_map_recursive::deserialize)
106        .map_err(RuleConfigError::from)?;
107    RuleConfig::try_from(inner, &GlobalRules::default())
108}
109
110fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
111    let ast_grep_rule = AstGrepRule {
112        id: rule.id.as_str(),
113        language: lang,
114        severity: &rule.severity,
115        message: &rule.message,
116        note: rule.note.as_deref(),
117        rule: &rule.rule,
118        files: rule.files.as_deref(),
119        ignores: rule.ignores.as_deref(),
120    };
121    compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
122        id: rule.id.clone(),
123        source,
124    })
125}
126
127/// Resolve a metric rule's threshold and compile its path globs.
128fn build_metric_rule(rule: &Rule) -> Result<MetricRule<'_>, ScanError> {
129    let Some((metric, threshold)) = rule.metric_spec() else {
130        return Err(ScanError::InvalidMetricRule {
131            id: rule.id.clone(),
132        });
133    };
134    Ok(MetricRule {
135        rule,
136        metric,
137        threshold,
138        files: rule.files.as_deref().map(build_glob_set).transpose()?,
139        ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
140    })
141}
142
143fn compile_rules(rules: &[Rule]) -> Result<CompiledRules<'_>, ScanError> {
144    // Metric rules must never reach ast-grep: their `rule` field is null and
145    // would fail to compile.
146    let (metric_rules, ast_rules): (Vec<&Rule>, Vec<&Rule>) =
147        rules.iter().partition(|r| r.is_metric());
148
149    let configs = ast_rules
150        .iter()
151        .copied()
152        .flat_map(|rule| {
153            rule.language
154                .ast_grep_langs()
155                .iter()
156                .map(move |lang| compile_rule(rule, *lang))
157        })
158        .collect::<Result<Vec<_>, _>>()?;
159    Ok(CompiledRules {
160        collection: RuleCollection::try_new(configs)?,
161        by_id: ast_rules
162            .iter()
163            .copied()
164            .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
165            .collect::<HashMap<_, _>>(),
166        metrics: metric_rules
167            .into_iter()
168            .map(build_metric_rule)
169            .collect::<Result<Vec<_>, _>>()?,
170    })
171}
172
173fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
174    let mut builder = GlobSetBuilder::new();
175    for pattern in patterns {
176        builder.add(Glob::new(pattern)?);
177    }
178    builder.build()
179}
180
181/// Whether `path` lives in a conventional test, bench, or example tree.
182///
183/// Rust integration tests, benchmarks, and examples are not `#[cfg(test)]`
184/// modules, so `CfgTestRanges` cannot see them. Rules with `skip_test_code`
185/// should treat these whole files as test code.
186///
187/// Matches exact directory names (`tests`, `benches`, `examples`) and also
188/// crate-level test directories whose name ends with `_test` or `_tests`
189/// (e.g. `integrations_tests`, `e2e_tests`).
190fn is_test_path(path: &Path) -> bool {
191    let in_test_dir = path.components().any(|c| {
192        let Some(name) = c.as_os_str().to_str() else {
193            return false;
194        };
195        matches!(name, "tests" | "benches" | "examples")
196            || name.ends_with("_tests")
197            || name.ends_with("_test")
198            || name.starts_with("test_")
199    });
200    let test_file_name = path
201        .file_stem()
202        .and_then(|s| s.to_str())
203        .is_some_and(|s| s.ends_with("_test") || s.ends_with("_tests"));
204    in_test_dir || test_file_name
205}
206
207/// One finding per metric rule whose measured value exceeds its threshold.
208///
209/// File-level findings have no source position, so they are anchored at 1:1.
210/// `skip_test_code` drops the whole file: a line-1 finding can never be inside
211/// a `#[cfg(test)]` block.
212fn metric_findings<D: Doc>(
213    metrics: &[&MetricRule],
214    path: &Path,
215    root: &AstGrep<D>,
216    source: &str,
217    lang: &Language,
218) -> Vec<Finding> {
219    metrics
220        .iter()
221        .filter(|m| !(m.rule.skip_test_code && is_test_path(path)))
222        .filter_map(|m| {
223            let value = metric::compute(m.metric, lang, root, source);
224            if !metric::exceeds(value, m.threshold) {
225                return None;
226            }
227            let rendered = m.metric.format_value(value);
228            Some(Finding {
229                rule_id: m.rule.id.clone(),
230                severity: m.rule.severity.clone(),
231                category: m.rule.category.clone().unwrap_or_default(),
232                message: m.rule.message.replace("$value", &rendered),
233                note: m.rule.note.clone(),
234                fix: m.rule.fix.clone(),
235                file: path.to_path_buf(),
236                line: 1,
237                column: 1,
238                end_line: 1,
239                end_column: 1,
240                matched_text: m.metric.describe(value),
241                confidence: None,
242                escalated: false,
243            })
244        })
245        .collect()
246}
247
248fn scan_file(path: &Path, lang: SupportLang, rules: &CompiledRules) -> Vec<Finding> {
249    let rule_lang = support_lang_to_language(lang);
250    let applicable = rules.collection.get_rule_from_lang(path, lang);
251    let metrics: Vec<&MetricRule> = rules
252        .metrics
253        .iter()
254        .filter(|m| m.applies_to(path, &rule_lang))
255        .collect();
256    if applicable.is_empty() && metrics.is_empty() {
257        return Vec::new();
258    }
259    let Ok(source) = read_to_string(path) else {
260        return Vec::new();
261    };
262
263    let root = lang.ast_grep(&source);
264
265    let mut findings: Vec<Finding> = if applicable.is_empty() {
266        Vec::new()
267    } else {
268        let combined = CombinedScan::new(applicable);
269        let result = combined.scan(&root, false);
270
271        let cfg_test = (lang == SupportLang::Rust && !result.matches.is_empty())
272            .then(|| CfgTestRanges::from_root(&root));
273        let cfg_test = cfg_test.as_ref();
274        let is_test_file = is_test_path(path);
275
276        result
277            .matches
278            .into_iter()
279            .filter_map(|(config, matches)| {
280                let rule = rules.by_id.get(&(config.id.as_str(), rule_lang.clone()))?;
281                Some((*rule, matches))
282            })
283            .flat_map(|(rule, matches)| {
284                let skip_test_code = rule.skip_test_code;
285                matches
286                    .into_iter()
287                    .map(move |node_match| {
288                        let start = node_match.start_pos();
289                        let end = node_match.end_pos();
290                        Finding {
291                            rule_id: rule.id.clone(),
292                            severity: rule.severity.clone(),
293                            category: rule.category.clone().unwrap_or_default(),
294                            message: rule.message.clone(),
295                            note: rule.note.clone(),
296                            fix: rule.fix.clone(),
297                            file: path.to_path_buf(),
298                            line: start.line() + 1,
299                            column: start.byte_point().1 + 1,
300                            end_line: end.line() + 1,
301                            end_column: end.byte_point().1 + 1,
302                            matched_text: node_match.text().to_string(),
303                            confidence: None,
304                            escalated: false,
305                        }
306                    })
307                    .filter(move |f| {
308                        if !skip_test_code {
309                            return true;
310                        }
311                        let in_test_code =
312                            is_test_file || cfg_test.is_some_and(|r| r.contains_line(f.line));
313                        !in_test_code
314                    })
315            })
316            .collect()
317    };
318
319    findings.extend(metric_findings(&metrics, path, &root, &source, &rule_lang));
320    filter_disabled(findings, &source)
321}
322
323/// Walk `paths` (gitignore-aware) and keep the source files slopguard can parse.
324fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
325    paths
326        .iter()
327        .flat_map(|path| WalkBuilder::new(path).build().flatten())
328        .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
329        .map(|entry| entry.into_path())
330        .filter(|path| !ignores.is_match(path))
331        .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
332        .collect()
333}
334
335/// Keep the given files as-is (no directory walk), dropping the ones that are
336/// ignored by config or written in an unsupported language.
337///
338/// Deliberately not gitignore-aware: a file git reports as changed must be
339/// scanned even when it lives under a hidden or ignored directory.
340fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
341    files
342        .iter()
343        .filter(|path| path.is_file())
344        .filter(|path| !ignores.is_match(path))
345        .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
346        .collect()
347}
348
349/// Count errors and warnings in one pass.
350pub fn count_severities(findings: &[Finding]) -> (usize, usize) {
351    let mut errors = 0;
352    let mut warnings = 0;
353    for finding in findings {
354        match finding.severity {
355            Severity::Error => errors += 1,
356            Severity::Warning => warnings += 1,
357        }
358    }
359    (errors, warnings)
360}
361
362/// Sort findings by location and drop duplicates from overlapping rules.
363fn normalize_findings(findings: &mut Vec<Finding>) {
364    findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
365    findings.dedup_by(|a, b| {
366        a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
367    });
368}
369
370/// Scan an already-collected file list without touching the cache.
371fn scan_collected(files: Vec<(PathBuf, SupportLang)>, compiled: &CompiledRules) -> ScanResult {
372    let mut findings: Vec<Finding> = files
373        .par_iter()
374        .flat_map_iter(|(path, lang)| scan_file(path, *lang, compiled))
375        .collect();
376    normalize_findings(&mut findings);
377
378    let (errors, warnings) = count_severities(&findings);
379
380    ScanResult {
381        findings,
382        stats: ScanStats {
383            errors,
384            warnings,
385            total: errors + warnings,
386            files_scanned: files.len(),
387            baseline_filtered: 0,
388            diff_base: None,
389            files_changed: None,
390        },
391        cache_stats: None,
392    }
393}
394
395/// Scan an already-collected file list, reusing cached findings for files
396/// whose content hash is unchanged.
397///
398/// `prune` drops cache entries that no longer correspond to a scanned file. A
399/// partial scan must pass `false`: it never saw the other files, so their
400/// entries are still valid.
401fn scan_collected_cached(
402    files: Vec<(PathBuf, SupportLang)>,
403    compiled: &CompiledRules,
404    rules: &[Rule],
405    cache_dir: &Path,
406    prune: bool,
407) -> ScanResult {
408    let current_rules_hash = rules_hash(rules);
409    let store = CacheStore::with_dir(cache_dir.to_path_buf()).scoped_to_rules(&current_rules_hash);
410    let rules_changed = store
411        .check_rules_changed(&current_rules_hash)
412        .unwrap_or(true);
413
414    let file_contents: Vec<(PathBuf, SupportLang, Vec<u8>, String)> = files
415        .into_iter()
416        .filter_map(|(path, lang)| {
417            let content = fs::read(&path).ok()?;
418            let hash = file_content_hash(&content);
419            Some((path, lang, content, hash))
420        })
421        .collect();
422
423    let mut cached_count = 0usize;
424    let mut changed_count = 0usize;
425    let mut all_findings: Vec<Finding> = Vec::new();
426
427    struct FileWork {
428        path: PathBuf,
429        lang: SupportLang,
430        hash: String,
431    }
432
433    let mut to_scan: Vec<FileWork> = Vec::new();
434
435    for (path, lang, _content, hash) in file_contents.iter() {
436        if !rules_changed {
437            if let Some(cached_findings) = store.get(hash) {
438                cached_count += 1;
439                all_findings.extend(cached_findings);
440                continue;
441            }
442        }
443        changed_count += 1;
444        to_scan.push(FileWork {
445            path: path.clone(),
446            lang: *lang,
447            hash: hash.clone(),
448        });
449    }
450
451    let scanned_findings: Vec<(String, Vec<Finding>)> = to_scan
452        .par_iter()
453        .map(|work| {
454            let findings = scan_file(&work.path, work.lang, compiled);
455            (work.hash.clone(), findings)
456        })
457        .collect();
458
459    for (hash, findings) in scanned_findings {
460        store.put(&hash, &findings).ok();
461        all_findings.extend(findings);
462    }
463
464    if prune {
465        let current_hashes: Vec<String> =
466            file_contents.iter().map(|(_, _, _, h)| h.clone()).collect();
467        store.cleanup(&current_hashes).ok();
468    }
469
470    normalize_findings(&mut all_findings);
471
472    let (errors, warnings) = count_severities(&all_findings);
473
474    let files_scanned = cached_count + changed_count;
475
476    ScanResult {
477        findings: all_findings,
478        stats: ScanStats {
479            errors,
480            warnings,
481            total: errors + warnings,
482            files_scanned,
483            baseline_filtered: 0,
484            diff_base: None,
485            files_changed: None,
486        },
487        cache_stats: Some(CacheStats {
488            cached: cached_count,
489            changed: changed_count,
490        }),
491    }
492}
493
494/// Scan the given paths for rule violations.
495pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
496    let compiled = compile_rules(rules)?;
497    let ignores = build_glob_set(&config.scan.ignores)?;
498    Ok(scan_collected(walk_files(paths, &ignores), &compiled))
499}
500
501/// Scan with file-level caching. Files whose content hash matches a cached
502/// entry (and whose rules have not changed) return cached findings without
503/// reparsing.
504///
505/// `cache_dir` is the directory where cache files are stored. When a custom
506/// cache directory is specified (via `--cache-dir`, `SLOPGUARD_CACHE_DIR`,
507/// or `scan.cache_dir` in config), pass it directly. Otherwise pass the
508/// project root and use `CacheStore::new` which appends `.slopguard-cache`.
509pub fn scan_cached(
510    paths: &[PathBuf],
511    rules: &[Rule],
512    config: &Config,
513    cache_dir: &Path,
514) -> Result<ScanResult, ScanError> {
515    let compiled = compile_rules(rules)?;
516    let ignores = build_glob_set(&config.scan.ignores)?;
517    Ok(scan_collected_cached(
518        walk_files(paths, &ignores),
519        &compiled,
520        rules,
521        cache_dir,
522        true,
523    ))
524}
525
526/// Scan an explicit list of files instead of walking directories.
527///
528/// Used by `--diff`, where git already produced the exact file set. Paths that
529/// no longer exist or that slopguard cannot parse are silently skipped.
530pub fn scan_files(
531    files: &[PathBuf],
532    rules: &[Rule],
533    config: &Config,
534) -> Result<ScanResult, ScanError> {
535    let compiled = compile_rules(rules)?;
536    let ignores = build_glob_set(&config.scan.ignores)?;
537    Ok(scan_collected(explicit_files(files, &ignores), &compiled))
538}
539
540/// Cached variant of [`scan_files`]. Cache pruning is skipped: a partial scan
541/// must not evict the entries of files it did not look at.
542pub fn scan_files_cached(
543    files: &[PathBuf],
544    rules: &[Rule],
545    config: &Config,
546    cache_dir: &Path,
547) -> Result<ScanResult, ScanError> {
548    let compiled = compile_rules(rules)?;
549    let ignores = build_glob_set(&config.scan.ignores)?;
550    Ok(scan_collected_cached(
551        explicit_files(files, &ignores),
552        &compiled,
553        rules,
554        cache_dir,
555        false,
556    ))
557}
558
559#[cfg(test)]
560mod tests {
561    use std::fs::{create_dir, write};
562
563    use tempfile::tempdir;
564
565    use super::*;
566    use crate::rule::{load_builtin_rules, parse_rule, RuleId};
567
568    fn unwrap_rule() -> Rule {
569        parse_rule(
570            r#"
571id: test-unwrap
572language: rust
573severity: error
574category: correctness
575message: ".unwrap() forbidden"
576rule:
577  kind: call_expression
578  regex: '\.unwrap\(\)\s*$'
579"#,
580        )
581        .unwrap()
582    }
583
584    fn scan_dir(dir: &Path, rules: &[Rule]) -> ScanResult {
585        scan(&[dir.to_path_buf()], rules, &Config::default()).unwrap()
586    }
587
588    #[test]
589    fn scan_finds_unwrap_violation() {
590        let dir = tempdir().unwrap();
591        write(
592            dir.path().join("main.rs"),
593            "fn main() {\n    foo().unwrap();\n}\n",
594        )
595        .unwrap();
596        let result = scan_dir(dir.path(), &[unwrap_rule()]);
597        assert_eq!(result.findings.len(), 1);
598        let f = &result.findings[0];
599        assert_eq!(f.rule_id, RuleId::from("test-unwrap"));
600        assert_eq!(f.severity, Severity::Error);
601        assert_eq!(f.line, 2);
602        assert!(f.matched_text.contains("unwrap()"));
603    }
604
605    #[test]
606    fn scan_clean_file_no_findings() {
607        let dir = tempdir().unwrap();
608        write(dir.path().join("main.rs"), "fn main() { let x = foo(); }\n").unwrap();
609        let result = scan_dir(dir.path(), &[unwrap_rule()]);
610        assert_eq!(result.findings.len(), 0);
611        assert_eq!(result.stats.files_scanned, 1);
612    }
613
614    #[test]
615    fn scan_unsupported_language_ignored() {
616        let dir = tempdir().unwrap();
617        write(dir.path().join("script.py"), "x.unwrap()\n").unwrap();
618        write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
619        let result = scan_dir(dir.path(), &[unwrap_rule()]);
620        assert_eq!(result.findings.len(), 0);
621        assert_eq!(result.stats.files_scanned, 1);
622    }
623
624    #[test]
625    fn scan_explicit_file_path() {
626        let dir = tempdir().unwrap();
627        let file = dir.path().join("main.rs");
628        write(&file, "fn main() { foo().unwrap(); }\n").unwrap();
629        let result = scan(&[file], &[unwrap_rule()], &Config::default()).unwrap();
630        assert_eq!(result.findings.len(), 1);
631        assert_eq!(result.stats.files_scanned, 1);
632    }
633
634    #[test]
635    fn rule_ignores_glob_respected() {
636        let dir = tempdir().unwrap();
637        let tests_dir = dir.path().join("tests");
638        create_dir(&tests_dir).unwrap();
639        write(
640            tests_dir.join("test_main.rs"),
641            "fn t() { foo().unwrap(); }\n",
642        )
643        .unwrap();
644        write(
645            dir.path().join("main.rs"),
646            "fn main() { foo().unwrap(); }\n",
647        )
648        .unwrap();
649        let rule = parse_rule(
650            r#"
651id: ign
652language: rust
653severity: error
654category: correctness
655message: x
656rule:
657  kind: call_expression
658  regex: '\.unwrap\(\)\s*$'
659ignores:
660  - "**/tests/**"
661"#,
662        )
663        .unwrap();
664        let result = scan_dir(dir.path(), &[rule]);
665        assert_eq!(result.findings.len(), 1);
666        assert!(result.findings[0]
667            .file
668            .to_string_lossy()
669            .contains("main.rs"));
670    }
671
672    #[test]
673    fn config_ignores_respected() {
674        let dir = tempdir().unwrap();
675        let gen = dir.path().join("generated");
676        create_dir(&gen).unwrap();
677        write(gen.join("out.rs"), "fn g() { foo().unwrap(); }\n").unwrap();
678        write(
679            dir.path().join("main.rs"),
680            "fn main() { foo().unwrap(); }\n",
681        )
682        .unwrap();
683        let mut cfg = Config::default();
684        cfg.scan.ignores = vec!["**/generated/**".to_string()];
685        let result = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap();
686        assert_eq!(result.stats.files_scanned, 1);
687        assert_eq!(result.findings.len(), 1);
688    }
689
690    #[test]
691    fn config_invalid_glob_is_an_error() {
692        let dir = tempdir().unwrap();
693        let mut cfg = Config::default();
694        cfg.scan.ignores = vec!["[".to_string()];
695        let err = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap_err();
696        assert!(matches!(err, ScanError::Glob(_)), "{err:?}");
697    }
698
699    #[test]
700    fn rule_without_kind_fails_to_compile() {
701        let dir = tempdir().unwrap();
702        let rule = parse_rule(
703            r#"
704id: no-kind
705language: rust
706severity: error
707category: correctness
708message: x
709rule:
710  regex: 'unwrap'
711"#,
712        )
713        .unwrap();
714        let err = scan(&[dir.path().to_path_buf()], &[rule], &Config::default()).unwrap_err();
715        assert!(
716            matches!(&err, ScanError::RuleCompile { id, .. } if *id == RuleId::from("no-kind")),
717            "{err:?}"
718        );
719    }
720
721    #[test]
722    fn stats_counting_accurate() {
723        let dir = tempdir().unwrap();
724        write(
725            dir.path().join("a.rs"),
726            "fn f() { foo().unwrap(); bar().unwrap(); }\n",
727        )
728        .unwrap();
729        let result = scan_dir(dir.path(), &[unwrap_rule()]);
730        assert_eq!(result.stats.errors, 2);
731        assert_eq!(result.stats.warnings, 0);
732        assert_eq!(result.stats.total, 2);
733        assert_eq!(result.stats.files_scanned, 1);
734    }
735
736    #[test]
737    fn scan_empty_file() {
738        let dir = tempdir().unwrap();
739        write(dir.path().join("empty.rs"), "").unwrap();
740        let result = scan_dir(dir.path(), &[unwrap_rule()]);
741        assert_eq!(result.findings.len(), 0);
742        assert_eq!(result.stats.files_scanned, 1);
743    }
744
745    #[test]
746    fn scan_complex_rule() {
747        let rule = parse_rule(
748            r#"
749id: dbg
750language: rust
751severity: error
752category: security
753message: debug on secret
754rule:
755  kind: attribute_item
756  all:
757    - regex: 'derive\([^)]*Debug'
758    - precedes:
759        kind: struct_item
760        has:
761          kind: field_identifier
762          regex: '^(api_key|password|secret)$'
763          stopBy: end
764"#,
765        )
766        .unwrap();
767        let dir = tempdir().unwrap();
768        write(
769            dir.path().join("t.rs"),
770            "#[derive(Debug)]\nstruct Config {\n    api_key: String,\n}\n",
771        )
772        .unwrap();
773        let result = scan_dir(dir.path(), &[rule]);
774        assert_eq!(result.findings.len(), 1);
775        assert_eq!(result.findings[0].rule_id, RuleId::from("dbg"));
776    }
777
778    #[test]
779    fn typescript_rule_applies_to_tsx_files() {
780        let rule = parse_rule(
781            r#"
782id: ts-any
783language: typescript
784severity: warning
785category: slop
786message: no any
787rule:
788  kind: predefined_type
789  regex: '^any$'
790"#,
791        )
792        .unwrap();
793        let dir = tempdir().unwrap();
794        write(dir.path().join("a.ts"), "let x: any = 1;\n").unwrap();
795        write(dir.path().join("b.tsx"), "let y: any = 2;\n").unwrap();
796        let result = scan_dir(dir.path(), &[rule]);
797        assert_eq!(result.stats.files_scanned, 2);
798        assert_eq!(result.stats.warnings, 2);
799    }
800
801    #[test]
802    fn scan_with_disable_comment_filters_findings() {
803        let dir = tempdir().unwrap();
804        write(
805            dir.path().join("main.rs"),
806            "fn main() {\n    // slopguard-disable-next-line\n    foo().unwrap();\n    bar().unwrap();\n}\n",
807        )
808        .unwrap();
809        let result = scan_dir(dir.path(), &[unwrap_rule()]);
810        assert_eq!(
811            result.findings.len(),
812            1,
813            "only the non-disabled unwrap should remain"
814        );
815        assert_eq!(result.findings[0].line, 4);
816    }
817
818    #[test]
819    fn scan_with_disable_rule_id_filters_only_that_rule() {
820        let dir = tempdir().unwrap();
821        write(
822            dir.path().join("main.rs"),
823            "fn main() {\n    // slopguard-disable-next-line test-unwrap\n    foo().unwrap();\n}\n",
824        )
825        .unwrap();
826        let result = scan_dir(dir.path(), &[unwrap_rule()]);
827        assert_eq!(result.findings.len(), 0);
828    }
829
830    fn unwrap_rule_skipping_test_code() -> Rule {
831        parse_rule(
832            r#"
833id: test-unwrap
834language: rust
835severity: error
836category: correctness
837message: ".unwrap() forbidden"
838rule:
839  kind: call_expression
840  regex: '\.unwrap\(\)\s*$'
841skip_test_code: true
842"#,
843        )
844        .unwrap()
845    }
846
847    #[test]
848    fn cfg_test_block_excluded_by_post_filter() {
849        let dir = tempdir().unwrap();
850        write(
851            dir.path().join("main.rs"),
852            "fn prod() {\n    foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n    fn test_it() {\n        bar().unwrap();\n    }\n}\n",
853        )
854        .unwrap();
855        let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
856        assert_eq!(
857            result.findings.len(),
858            1,
859            "should only find the prod unwrap, not the one inside #[cfg(test)]"
860        );
861        assert_eq!(result.findings[0].line, 2);
862    }
863
864    #[test]
865    fn cfg_test_kept_without_skip_test_code() {
866        let dir = tempdir().unwrap();
867        write(
868            dir.path().join("main.rs"),
869            "fn prod() {\n    foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n    fn test_it() {\n        bar().unwrap();\n    }\n}\n",
870        )
871        .unwrap();
872        let result = scan_dir(dir.path(), &[unwrap_rule()]);
873        assert_eq!(
874            result.findings.len(),
875            2,
876            "rule without skip_test_code should find both"
877        );
878    }
879
880    #[test]
881    fn integration_test_file_excluded_by_skip_test_code() {
882        let dir = tempdir().unwrap();
883        let tests_dir = dir.path().join("tests");
884        std::fs::create_dir(&tests_dir).unwrap();
885        write(
886            tests_dir.join("it.rs"),
887            "fn check() {\n    foo().unwrap();\n}\n",
888        )
889        .unwrap();
890        let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
891        assert_eq!(
892            result.findings.len(),
893            0,
894            "unwrap inside a tests/ integration file should be skipped"
895        );
896    }
897
898    #[test]
899    fn integration_test_file_kept_without_skip_test_code() {
900        let dir = tempdir().unwrap();
901        let tests_dir = dir.path().join("tests");
902        std::fs::create_dir(&tests_dir).unwrap();
903        write(
904            tests_dir.join("it.rs"),
905            "fn check() {\n    foo().unwrap();\n}\n",
906        )
907        .unwrap();
908        let result = scan_dir(dir.path(), &[unwrap_rule()]);
909        assert_eq!(
910            result.findings.len(),
911            1,
912            "rule without skip_test_code should still flag tests/ files"
913        );
914    }
915
916    #[test]
917    fn test_crate_directory_excluded_by_skip_test_code() {
918        let dir = tempdir().unwrap();
919        let test_crate = dir
920            .path()
921            .join("crates")
922            .join("integrations_tests")
923            .join("src");
924        std::fs::create_dir_all(&test_crate).unwrap();
925        write(
926            test_crate.join("api.rs"),
927            "fn check() {\n    foo().unwrap();\n}\n",
928        )
929        .unwrap();
930        let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
931        assert_eq!(
932            result.findings.len(),
933            0,
934            "unwrap inside a *_tests crate should be skipped"
935        );
936    }
937
938    #[test]
939    fn test_crate_directory_kept_without_skip_test_code() {
940        let dir = tempdir().unwrap();
941        let test_crate = dir
942            .path()
943            .join("crates")
944            .join("integrations_tests")
945            .join("src");
946        std::fs::create_dir_all(&test_crate).unwrap();
947        write(
948            test_crate.join("api.rs"),
949            "fn check() {\n    foo().unwrap();\n}\n",
950        )
951        .unwrap();
952        let result = scan_dir(dir.path(), &[unwrap_rule()]);
953        assert_eq!(
954            result.findings.len(),
955            1,
956            "rule without skip_test_code should still flag *_tests crate files"
957        );
958    }
959
960    #[test]
961    fn all_builtin_rules_compile() {
962        let rules = load_builtin_rules().unwrap();
963        assert!(
964            rules.len() >= 55,
965            "expected at least 55 rules, got {}",
966            rules.len()
967        );
968        let compiled = compile_rules(&rules).expect("all builtin rules should compile");
969        let ast_rule_count = rules.iter().filter(|r| !r.is_metric()).count();
970        assert_eq!(compiled.by_id.len(), ast_rule_count);
971        assert!(
972            !compiled.metrics.is_empty(),
973            "builtin rules should include metric rules"
974        );
975    }
976
977    #[test]
978    fn scan_files_only_scans_given_files() {
979        let dir = tempdir().unwrap();
980        let a = dir.path().join("a.rs");
981        let b = dir.path().join("b.rs");
982        write(&a, "fn a() {\n    foo().unwrap();\n}\n").unwrap();
983        write(&b, "fn b() {\n    bar().unwrap();\n}\n").unwrap();
984
985        let result = scan_files(&[a], &[unwrap_rule()], &Config::default()).unwrap();
986        assert_eq!(result.findings.len(), 1);
987        assert_eq!(result.stats.files_scanned, 1);
988        assert!(result.findings[0].file.to_string_lossy().contains("a.rs"));
989    }
990
991    #[test]
992    fn scan_files_skips_unsupported_language() {
993        let dir = tempdir().unwrap();
994        let py = dir.path().join("script.py");
995        write(&py, "x.unwrap()\n").unwrap();
996
997        let result = scan_files(&[py], &[unwrap_rule()], &Config::default()).unwrap();
998        assert_eq!(result.findings.len(), 0);
999        assert_eq!(result.stats.files_scanned, 0);
1000    }
1001
1002    #[test]
1003    fn scan_files_skips_missing_file() {
1004        let dir = tempdir().unwrap();
1005        let missing = dir.path().join("gone.rs");
1006
1007        let result = scan_files(&[missing], &[unwrap_rule()], &Config::default()).unwrap();
1008        assert_eq!(result.findings.len(), 0);
1009        assert_eq!(result.stats.files_scanned, 0);
1010    }
1011
1012    #[test]
1013    fn scan_files_respects_config_ignores() {
1014        let dir = tempdir().unwrap();
1015        let gen = dir.path().join("generated");
1016        create_dir(&gen).unwrap();
1017        let out = gen.join("out.rs");
1018        write(&out, "fn g() { foo().unwrap(); }\n").unwrap();
1019
1020        let mut cfg = Config::default();
1021        cfg.scan.ignores = vec!["**/generated/**".to_string()];
1022        let result = scan_files(&[out], &[unwrap_rule()], &cfg).unwrap();
1023        assert_eq!(result.findings.len(), 0);
1024        assert_eq!(result.stats.files_scanned, 0);
1025    }
1026
1027    #[test]
1028    fn scan_files_cached_keeps_other_entries() {
1029        let src_dir = tempdir().unwrap();
1030        let cache_dir = tempdir().unwrap();
1031        let cache_path = cache_dir.path().join("cache");
1032        let a = src_dir.path().join("a.rs");
1033        let b = src_dir.path().join("b.rs");
1034        write(&a, "fn a() {\n    foo().unwrap();\n}\n").unwrap();
1035        write(&b, "fn b() {\n    bar().unwrap();\n}\n").unwrap();
1036
1037        let rules = [unwrap_rule()];
1038        let config = Config::default();
1039        let paths = [src_dir.path().to_path_buf()];
1040
1041        scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1042
1043        // A partial scan must not evict the entry of the file it skipped.
1044        scan_files_cached(&[a], &rules, &config, &cache_path).unwrap();
1045
1046        let result = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1047        let stats = result.cache_stats.as_ref().unwrap();
1048        assert_eq!(stats.cached, 2, "a partial scan must not prune");
1049        assert_eq!(stats.changed, 0);
1050    }
1051
1052    fn metric_rule(body: &str) -> Rule {
1053        parse_rule(body).unwrap()
1054    }
1055
1056    fn file_lines_rule() -> Rule {
1057        metric_rule(
1058            r#"
1059id: test-file-lines
1060language: rust
1061severity: warning
1062category: slop
1063metric: file_lines
1064threshold: 10
1065message: "File is too long"
1066"#,
1067        )
1068    }
1069
1070    /// `n` lines of trivial Rust, one statement per line.
1071    fn rust_lines(n: usize) -> String {
1072        (0..n).map(|i| format!("// line {i}\n")).collect()
1073    }
1074
1075    #[test]
1076    fn scan_finds_file_lines_violation() {
1077        let dir = tempdir().unwrap();
1078        write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1079        let result = scan_dir(dir.path(), &[file_lines_rule()]);
1080        assert_eq!(result.findings.len(), 1);
1081        let f = &result.findings[0];
1082        assert_eq!(f.rule_id, RuleId::from("test-file-lines"));
1083        assert_eq!(f.line, 1);
1084        assert_eq!(f.column, 1);
1085        assert_eq!(f.end_line, 1);
1086        assert_eq!(f.end_column, 1);
1087        assert_eq!(f.matched_text, "12 lines");
1088    }
1089
1090    #[test]
1091    fn metric_message_interpolates_value() {
1092        let dir = tempdir().unwrap();
1093        write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1094        let rule = metric_rule(
1095            r#"
1096id: test-interpolate
1097language: rust
1098severity: warning
1099category: slop
1100metric: file_lines
1101threshold: 10
1102message: "File exceeds 10 lines ($value lines)"
1103"#,
1104        );
1105        let result = scan_dir(dir.path(), &[rule]);
1106        assert_eq!(result.findings.len(), 1);
1107        let message = &result.findings[0].message;
1108        assert!(message.contains("(12 lines)"), "got: {message}");
1109        assert!(!message.contains("$value"), "got: {message}");
1110    }
1111
1112    #[test]
1113    fn metric_rule_below_threshold_no_finding() {
1114        let dir = tempdir().unwrap();
1115        write(dir.path().join("exact.rs"), rust_lines(10)).unwrap();
1116        let result = scan_dir(dir.path(), &[file_lines_rule()]);
1117        assert_eq!(
1118            result.findings.len(),
1119            0,
1120            "a file of exactly the threshold length must not fire"
1121        );
1122    }
1123
1124    #[test]
1125    fn metric_comment_ratio_finding() {
1126        let dir = tempdir().unwrap();
1127        write(
1128            dir.path().join("commented.rs"),
1129            "// a\n// b\n// c\n// d\nfn f() {}\nfn g() {}\nfn h() {}\n",
1130        )
1131        .unwrap();
1132        let rule = metric_rule(
1133            r#"
1134id: test-comment-ratio
1135language: rust
1136severity: warning
1137category: slop
1138metric: comment_ratio
1139threshold: 0.4
1140message: "Too many comments ($value)"
1141"#,
1142        );
1143        let result = scan_dir(dir.path(), &[rule]);
1144        assert_eq!(result.findings.len(), 1);
1145        assert!(
1146            result.findings[0].matched_text.ends_with("comment ratio"),
1147            "got: {}",
1148            result.findings[0].matched_text
1149        );
1150    }
1151
1152    #[test]
1153    fn metric_rule_respects_ignores_glob() {
1154        let dir = tempdir().unwrap();
1155        let gen = dir.path().join("generated");
1156        create_dir(&gen).unwrap();
1157        write(gen.join("out.rs"), rust_lines(12)).unwrap();
1158        write(dir.path().join("main.rs"), rust_lines(12)).unwrap();
1159        let rule = metric_rule(
1160            r#"
1161id: test-metric-ignores
1162language: rust
1163severity: warning
1164category: slop
1165metric: file_lines
1166threshold: 10
1167message: "too long"
1168ignores:
1169  - "**/generated/**"
1170"#,
1171        );
1172        let result = scan_dir(dir.path(), &[rule]);
1173        assert_eq!(result.findings.len(), 1);
1174        assert!(result.findings[0]
1175            .file
1176            .to_string_lossy()
1177            .contains("main.rs"));
1178    }
1179
1180    #[test]
1181    fn metric_rule_respects_files_glob() {
1182        let dir = tempdir().unwrap();
1183        let src = dir.path().join("src");
1184        create_dir(&src).unwrap();
1185        write(src.join("lib.rs"), rust_lines(12)).unwrap();
1186        write(dir.path().join("build.rs"), rust_lines(12)).unwrap();
1187        let rule = metric_rule(
1188            r#"
1189id: test-metric-files
1190language: rust
1191severity: warning
1192category: slop
1193metric: file_lines
1194threshold: 10
1195message: "too long"
1196files:
1197  - "**/src/**/*.rs"
1198"#,
1199        );
1200        let result = scan_dir(dir.path(), &[rule]);
1201        assert_eq!(result.findings.len(), 1);
1202        assert!(result.findings[0].file.to_string_lossy().contains("lib.rs"));
1203    }
1204
1205    #[test]
1206    fn metric_rule_skip_test_code_skips_test_files() {
1207        let dir = tempdir().unwrap();
1208        let tests_dir = dir.path().join("tests");
1209        create_dir(&tests_dir).unwrap();
1210        write(tests_dir.join("it.rs"), rust_lines(12)).unwrap();
1211
1212        let skipping = metric_rule(
1213            r#"
1214id: test-metric-skip
1215language: rust
1216severity: warning
1217category: slop
1218metric: file_lines
1219threshold: 10
1220message: "too long"
1221skip_test_code: true
1222"#,
1223        );
1224        let skipped = scan_dir(dir.path(), &[skipping]);
1225        assert_eq!(skipped.findings.len(), 0);
1226        let reported = scan_dir(dir.path(), &[file_lines_rule()]);
1227        assert_eq!(reported.findings.len(), 1);
1228    }
1229
1230    #[test]
1231    fn metric_only_ruleset_still_scans() {
1232        let dir = tempdir().unwrap();
1233        write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1234        let result = scan_dir(dir.path(), &[file_lines_rule()]);
1235        assert_eq!(
1236            result.findings.len(),
1237            1,
1238            "a rule set with only metric rules must still scan files"
1239        );
1240    }
1241
1242    #[test]
1243    fn metric_and_ast_rules_together() {
1244        let dir = tempdir().unwrap();
1245        let mut source = String::from("fn main() {\n    foo().unwrap();\n}\n");
1246        source.push_str(&rust_lines(12));
1247        write(dir.path().join("main.rs"), source).unwrap();
1248
1249        let result = scan_dir(dir.path(), &[unwrap_rule(), file_lines_rule()]);
1250        assert_eq!(result.findings.len(), 2);
1251        let first = &result.findings[0];
1252        assert_eq!(first.rule_id, RuleId::from("test-file-lines"));
1253        assert_eq!(first.line, 1);
1254        let second = &result.findings[1];
1255        assert_eq!(second.rule_id, RuleId::from("test-unwrap"));
1256    }
1257
1258    #[test]
1259    fn metric_rule_wrong_language_not_applied() {
1260        let dir = tempdir().unwrap();
1261        write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1262        let rule = metric_rule(
1263            r#"
1264id: test-metric-ts
1265language: typescript
1266severity: warning
1267category: slop
1268metric: file_lines
1269threshold: 10
1270message: "too long"
1271"#,
1272        );
1273        let result = scan_dir(dir.path(), &[rule]);
1274        assert_eq!(result.findings.len(), 0);
1275    }
1276
1277    #[test]
1278    fn metric_findings_are_cached() {
1279        let src_dir = tempdir().unwrap();
1280        let cache_dir = tempdir().unwrap();
1281        let cache_path = cache_dir.path().join("cache");
1282        write(src_dir.path().join("big.rs"), rust_lines(12)).unwrap();
1283
1284        let rules = [file_lines_rule()];
1285        let config = Config::default();
1286        let paths = [src_dir.path().to_path_buf()];
1287
1288        let first = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1289        assert_eq!(first.findings.len(), 1);
1290        assert_eq!(first.cache_stats.as_ref().unwrap().changed, 1);
1291
1292        let second = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1293        assert_eq!(second.cache_stats.as_ref().unwrap().cached, 1);
1294        assert_eq!(second.findings.len(), 1);
1295        assert_eq!(second.findings[0].matched_text, "12 lines");
1296    }
1297
1298    #[test]
1299    fn scan_with_cache() {
1300        let src_dir = tempdir().unwrap();
1301        let cache_dir = tempdir().unwrap();
1302        let cache_path = cache_dir.path().join("cache");
1303        write(
1304            src_dir.path().join("main.rs"),
1305            "fn main() {\n    foo().unwrap();\n}\n",
1306        )
1307        .unwrap();
1308
1309        let rules = [unwrap_rule()];
1310        let config = Config::default();
1311
1312        let result1 = scan_cached(
1313            &[src_dir.path().to_path_buf()],
1314            &rules,
1315            &config,
1316            &cache_path,
1317        )
1318        .unwrap();
1319
1320        assert_eq!(result1.findings.len(), 1);
1321        let cache_stats1 = result1.cache_stats.as_ref().unwrap();
1322        assert_eq!(cache_stats1.cached, 0, "first scan: nothing cached");
1323        assert_eq!(cache_stats1.changed, 1, "first scan: one file scanned");
1324
1325        let result2 = scan_cached(
1326            &[src_dir.path().to_path_buf()],
1327            &rules,
1328            &config,
1329            &cache_path,
1330        )
1331        .unwrap();
1332
1333        assert_eq!(result2.findings.len(), 1);
1334        let cache_stats2 = result2.cache_stats.as_ref().unwrap();
1335        assert_eq!(cache_stats2.cached, 1, "second scan: one file from cache");
1336        assert_eq!(cache_stats2.changed, 0, "second scan: nothing changed");
1337    }
1338}