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, CacheEntry, CacheStore};
22use crate::config::Config;
23use crate::cross_file::{self, CrossFileKind, DeclFilter, FileSymbols, SymbolIndex};
24use crate::disable::filter_disabled;
25use crate::finding::{CacheStats, Finding, ScanResult, ScanStats};
26use crate::metric::{self, Metric};
27use crate::rule::{Language, Rule, RuleId, Severity};
28use crate::test_filter::{is_test_path, CfgTestRanges};
29
30#[derive(Debug, Error)]
31pub enum ScanError {
32    #[error("failed to compile rule '{id}': {source}")]
33    RuleCompile {
34        id: RuleId,
35        #[source]
36        source: RuleConfigError,
37    },
38
39    #[error("invalid glob pattern: {0}")]
40    Glob(#[from] GlobError),
41
42    #[error("rule '{id}': metric rule is missing a threshold")]
43    InvalidMetricRule { id: RuleId },
44}
45
46/// The subset of a slopguard rule that ast-grep understands. slopguard-only
47/// fields (`category`, `fix` as free text, `tests`) are deliberately left out.
48#[derive(Serialize)]
49pub struct AstGrepRule<'a> {
50    pub id: &'a str,
51    pub language: SupportLang,
52    pub severity: &'a Severity,
53    pub message: &'a str,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub note: Option<&'a str>,
56    pub rule: &'a Value,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub files: Option<&'a [String]>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub ignores: Option<&'a [String]>,
61}
62
63/// Rules compiled for scanning, plus a way back to the slopguard rule that
64/// produced each ast-grep config.
65struct CompiledRules<'a> {
66    collection: RuleCollection<SupportLang>,
67    by_id: HashMap<(&'a str, Language), &'a Rule>,
68    metrics: Vec<MetricRule<'a>>,
69    cross_file: Vec<CrossFileRule<'a>>,
70}
71
72/// A cross-file rule prepared for scanning: kind resolved and the declaration
73/// filter compiled once.
74struct CrossFileRule<'a> {
75    rule: &'a Rule,
76    kind: CrossFileKind,
77    decl_filter: DeclFilter,
78}
79
80/// A metric rule prepared for scanning: threshold resolved and `files` /
81/// `ignores` globs compiled once. ast-grep applies those globs for AST rules;
82/// metric rules are evaluated outside ast-grep so they apply them here.
83struct MetricRule<'a> {
84    rule: &'a Rule,
85    metric: Metric,
86    threshold: f64,
87    files: Option<GlobSet>,
88    ignores: Option<GlobSet>,
89}
90
91impl MetricRule<'_> {
92    fn applies_to(&self, path: &Path, lang: &Language) -> bool {
93        self.rule.language == *lang
94            && self.files.as_ref().is_none_or(|g| g.is_match(path))
95            && !self.ignores.as_ref().is_some_and(|g| g.is_match(path))
96    }
97}
98
99fn extension_to_lang(path: &Path) -> Option<SupportLang> {
100    SupportLang::from_path(path)
101        .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
102}
103
104fn support_lang_to_language(lang: SupportLang) -> Language {
105    match lang {
106        SupportLang::Rust => Language::Rust,
107        _ => Language::TypeScript,
108    }
109}
110
111pub(crate) fn compile_ast_grep_rule(
112    rule: &AstGrepRule,
113) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
114    let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
115        .and_then(singleton_map_recursive::deserialize)
116        .map_err(RuleConfigError::from)?;
117    RuleConfig::try_from(inner, &GlobalRules::default())
118}
119
120fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
121    let ast_grep_rule = AstGrepRule {
122        id: rule.id.as_str(),
123        language: lang,
124        severity: &rule.severity,
125        message: &rule.message,
126        note: rule.note.as_deref(),
127        rule: &rule.rule,
128        files: rule.files.as_deref(),
129        ignores: rule.ignores.as_deref(),
130    };
131    compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
132        id: rule.id.clone(),
133        source,
134    })
135}
136
137/// Resolve a metric rule's threshold and compile its path globs.
138fn build_metric_rule(rule: &Rule) -> Result<MetricRule<'_>, ScanError> {
139    let Some((metric, threshold)) = rule.metric_spec() else {
140        return Err(ScanError::InvalidMetricRule {
141            id: rule.id.clone(),
142        });
143    };
144    Ok(MetricRule {
145        rule,
146        metric,
147        threshold,
148        files: rule.files.as_deref().map(build_glob_set).transpose()?,
149        ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
150    })
151}
152
153/// Compile the globs that bound which declarations a cross-file rule may
154/// report. The `kind` is resolved by the caller, which already partitioned the
155/// rules on `is_cross_file()`.
156fn build_cross_file_rule(rule: &Rule, kind: CrossFileKind) -> Result<CrossFileRule<'_>, ScanError> {
157    Ok(CrossFileRule {
158        rule,
159        kind,
160        decl_filter: DeclFilter {
161            files: rule.files.as_deref().map(build_glob_set).transpose()?,
162            ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
163            skip_test_code: rule.skip_test_code,
164        },
165    })
166}
167
168fn compile_rules(rules: &[Rule]) -> Result<CompiledRules<'_>, ScanError> {
169    // Neither cross-file nor metric rules may reach ast-grep: their `rule`
170    // field is null and would fail to compile.
171    let (cross_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
172        rules.iter().partition(|r| r.is_cross_file());
173    let (metric_rules, ast_rules): (Vec<&Rule>, Vec<&Rule>) =
174        rest.into_iter().partition(|r| r.is_metric());
175
176    let configs = ast_rules
177        .iter()
178        .copied()
179        .flat_map(|rule| {
180            rule.language
181                .ast_grep_langs()
182                .iter()
183                .map(move |lang| compile_rule(rule, *lang))
184        })
185        .collect::<Result<Vec<_>, _>>()?;
186    Ok(CompiledRules {
187        collection: RuleCollection::try_new(configs)?,
188        by_id: ast_rules
189            .iter()
190            .copied()
191            .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
192            .collect::<HashMap<_, _>>(),
193        metrics: metric_rules
194            .into_iter()
195            .map(build_metric_rule)
196            .collect::<Result<Vec<_>, _>>()?,
197        cross_file: cross_rules
198            .into_iter()
199            .filter_map(|rule| rule.cross_file_kind().map(|kind| (rule, kind)))
200            .map(|(rule, kind)| build_cross_file_rule(rule, kind))
201            .collect::<Result<Vec<_>, _>>()?,
202    })
203}
204
205fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
206    let mut builder = GlobSetBuilder::new();
207    for pattern in patterns {
208        builder.add(Glob::new(pattern)?);
209    }
210    builder.build()
211}
212
213/// One finding per metric rule whose measured value exceeds its threshold.
214///
215/// File-level findings have no source position, so they are anchored at 1:1.
216/// `skip_test_code` drops the whole file: a line-1 finding can never be inside
217/// a `#[cfg(test)]` block.
218fn metric_findings<D: Doc>(
219    metrics: &[&MetricRule],
220    path: &Path,
221    root: &AstGrep<D>,
222    source: &str,
223    lang: &Language,
224) -> Vec<Finding> {
225    metrics
226        .iter()
227        .filter(|m| !(m.rule.skip_test_code && is_test_path(path)))
228        .filter_map(|m| {
229            let value = metric::compute(m.metric, lang, root, source);
230            if !metric::exceeds(value, m.threshold) {
231                return None;
232            }
233            let rendered = m.metric.format_value(value);
234            Some(Finding {
235                rule_id: m.rule.id.clone(),
236                severity: m.rule.severity.clone(),
237                category: m.rule.category.clone().unwrap_or_default(),
238                message: m.rule.message.replace("$value", &rendered),
239                note: m.rule.note.clone(),
240                fix: m.rule.fix.clone(),
241                file: path.to_path_buf(),
242                line: 1,
243                column: 1,
244                end_line: 1,
245                end_column: 1,
246                matched_text: m.metric.describe(value),
247                confidence: None,
248                escalated: false,
249            })
250        })
251        .collect()
252}
253
254/// One file's contribution to a scan: its findings plus, when a cross-file
255/// rule is active, the symbols it adds to the project index. Both come out of
256/// a single parse.
257#[derive(Default)]
258struct FileScan {
259    findings: Vec<Finding>,
260    symbols: FileSymbols,
261}
262
263fn scan_file(
264    path: &Path,
265    lang: SupportLang,
266    rules: &CompiledRules,
267    collect_symbols: bool,
268) -> FileScan {
269    let rule_lang = support_lang_to_language(lang);
270    let applicable = rules.collection.get_rule_from_lang(path, lang);
271    let metrics: Vec<&MetricRule> = rules
272        .metrics
273        .iter()
274        .filter(|m| m.applies_to(path, &rule_lang))
275        .collect();
276    // A run scoped to a single cross-file rule has no ast-grep and no metric
277    // rule, yet the file still has to be parsed for its symbols.
278    let want_symbols = collect_symbols && lang == SupportLang::Rust;
279    if applicable.is_empty() && metrics.is_empty() && !want_symbols {
280        return FileScan::default();
281    }
282    let Ok(source) = read_to_string(path) else {
283        return FileScan::default();
284    };
285
286    let root = lang.ast_grep(&source);
287    // Built once and shared by the finding filter and the symbol extractor.
288    let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&root));
289
290    let mut findings: Vec<Finding> = if applicable.is_empty() {
291        Vec::new()
292    } else {
293        let combined = CombinedScan::new(applicable);
294        let result = combined.scan(&root, false);
295
296        let cfg_test = cfg_test.as_ref();
297        let is_test_file = is_test_path(path);
298
299        result
300            .matches
301            .into_iter()
302            .filter_map(|(config, matches)| {
303                let rule = rules.by_id.get(&(config.id.as_str(), rule_lang.clone()))?;
304                Some((*rule, matches))
305            })
306            .flat_map(|(rule, matches)| {
307                let skip_test_code = rule.skip_test_code;
308                matches
309                    .into_iter()
310                    .map(move |node_match| {
311                        let start = node_match.start_pos();
312                        let end = node_match.end_pos();
313                        Finding {
314                            rule_id: rule.id.clone(),
315                            severity: rule.severity.clone(),
316                            category: rule.category.clone().unwrap_or_default(),
317                            message: rule.message.clone(),
318                            note: rule.note.clone(),
319                            fix: rule.fix.clone(),
320                            file: path.to_path_buf(),
321                            line: start.line() + 1,
322                            column: start.byte_point().1 + 1,
323                            end_line: end.line() + 1,
324                            end_column: end.byte_point().1 + 1,
325                            matched_text: node_match.text().to_string(),
326                            confidence: None,
327                            escalated: false,
328                        }
329                    })
330                    .filter(move |f| {
331                        if !skip_test_code {
332                            return true;
333                        }
334                        let in_test_code =
335                            is_test_file || cfg_test.is_some_and(|r| r.contains_line(f.line));
336                        !in_test_code
337                    })
338            })
339            .collect()
340    };
341
342    findings.extend(metric_findings(&metrics, path, &root, &source, &rule_lang));
343
344    // Symbols are not run through `filter_disabled`: suppression applies to the
345    // cross-file findings emitted later, against the declaration file's source.
346    let symbols = match (want_symbols, cfg_test.as_ref()) {
347        (true, Some(ranges)) => cross_file::extract_rust_symbols(&root, ranges),
348        _ => FileSymbols::default(),
349    };
350
351    FileScan {
352        findings: filter_disabled(findings, &source),
353        symbols,
354    }
355}
356
357/// Assemble the project index from every file's contribution, evaluate each
358/// active cross-file rule, then drop the findings a disable comment suppresses
359/// in the file that declares the symbol.
360fn cross_file_findings(
361    rules: &[CrossFileRule<'_>],
362    contributions: &[(PathBuf, FileSymbols)],
363) -> Vec<Finding> {
364    let index = SymbolIndex::build(contributions.iter().map(|(p, s)| (p.as_path(), s)));
365    let mut by_file: HashMap<PathBuf, Vec<Finding>> = HashMap::new();
366    for rule in rules {
367        let found = cross_file::evaluate(rule.rule, rule.kind, &index, &rule.decl_filter);
368        for finding in found {
369            by_file
370                .entry(finding.file.clone())
371                .or_default()
372                .push(finding);
373        }
374    }
375    by_file
376        .into_iter()
377        .flat_map(|(path, findings)| match read_to_string(&path) {
378            // A file that can no longer be read keeps its findings unfiltered
379            // rather than losing them.
380            Ok(source) => filter_disabled(findings, &source),
381            Err(_) => findings,
382        })
383        .collect()
384}
385
386/// Run the cross-file pass and append its findings, if it is due. A full scan
387/// asks for it via `run_cross_file`; a partial scan (`--diff`, explicit files)
388/// collects contributions to warm the cache but leaves the index incomplete, so
389/// it must not evaluate. Centralises the `run_cross_file && has cross-file
390/// rules` invariant shared by both scan paths.
391fn append_cross_file(
392    findings: &mut Vec<Finding>,
393    compiled: &CompiledRules,
394    run_cross_file: bool,
395    contributions: &[(PathBuf, FileSymbols)],
396) {
397    if run_cross_file && !compiled.cross_file.is_empty() {
398        findings.extend(cross_file_findings(&compiled.cross_file, contributions));
399    }
400}
401
402/// Walk `paths` (gitignore-aware) and keep the source files slopguard can parse.
403fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
404    paths
405        .iter()
406        .flat_map(|path| WalkBuilder::new(path).build().flatten())
407        .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
408        .map(|entry| entry.into_path())
409        .filter(|path| !ignores.is_match(path))
410        .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
411        .collect()
412}
413
414/// Keep the given files as-is (no directory walk), dropping the ones that are
415/// ignored by config or written in an unsupported language.
416///
417/// Deliberately not gitignore-aware: a file git reports as changed must be
418/// scanned even when it lives under a hidden or ignored directory.
419fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
420    files
421        .iter()
422        .filter(|path| path.is_file())
423        .filter(|path| !ignores.is_match(path))
424        .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
425        .collect()
426}
427
428/// Count errors and warnings in one pass.
429pub fn count_severities(findings: &[Finding]) -> (usize, usize) {
430    let mut errors = 0;
431    let mut warnings = 0;
432    for finding in findings {
433        match finding.severity {
434            Severity::Error => errors += 1,
435            Severity::Warning => warnings += 1,
436        }
437    }
438    (errors, warnings)
439}
440
441/// Sort findings by location and drop duplicates from overlapping rules.
442fn normalize_findings(findings: &mut Vec<Finding>) {
443    findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
444    findings.dedup_by(|a, b| {
445        a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
446    });
447}
448
449/// Scan an already-collected file list without touching the cache.
450///
451/// Symbol collection is driven by the active rules alone, never by
452/// `run_cross_file`: a partial scan must still cache a complete contribution.
453fn scan_collected(
454    files: Vec<(PathBuf, SupportLang)>,
455    compiled: &CompiledRules,
456    run_cross_file: bool,
457) -> ScanResult {
458    let collect_symbols = !compiled.cross_file.is_empty();
459    let scans: Vec<(PathBuf, FileScan)> = files
460        .par_iter()
461        .map(|(path, lang)| {
462            let scan = scan_file(path, *lang, compiled, collect_symbols);
463            (path.clone(), scan)
464        })
465        .collect();
466
467    let mut findings: Vec<Finding> = Vec::new();
468    let mut contributions: Vec<(PathBuf, FileSymbols)> = Vec::new();
469    for (path, scan) in scans {
470        findings.extend(scan.findings);
471        if collect_symbols {
472            contributions.push((path, scan.symbols));
473        }
474    }
475    append_cross_file(&mut findings, compiled, run_cross_file, &contributions);
476    normalize_findings(&mut findings);
477
478    let (errors, warnings) = count_severities(&findings);
479
480    ScanResult {
481        findings,
482        stats: ScanStats {
483            errors,
484            warnings,
485            total: errors + warnings,
486            files_scanned: files.len(),
487            baseline_filtered: 0,
488            diff_base: None,
489            files_changed: None,
490        },
491        cache_stats: None,
492    }
493}
494
495/// Scan an already-collected file list, reusing cached findings for files
496/// whose content hash is unchanged.
497///
498/// `prune` drops cache entries that no longer correspond to a scanned file. A
499/// partial scan must pass `false`: it never saw the other files, so their
500/// entries are still valid.
501fn scan_collected_cached(
502    files: Vec<(PathBuf, SupportLang)>,
503    compiled: &CompiledRules,
504    rules: &[Rule],
505    cache_dir: &Path,
506    prune: bool,
507    run_cross_file: bool,
508) -> ScanResult {
509    let collect_symbols = !compiled.cross_file.is_empty();
510    let current_rules_hash = rules_hash(rules);
511    let store = CacheStore::with_dir(cache_dir.to_path_buf()).scoped_to_rules(&current_rules_hash);
512    let rules_changed = store
513        .check_rules_changed(&current_rules_hash)
514        .unwrap_or(true);
515
516    let file_contents: Vec<(PathBuf, SupportLang, Vec<u8>, String)> = files
517        .into_iter()
518        .filter_map(|(path, lang)| {
519            let content = fs::read(&path).ok()?;
520            let hash = file_content_hash(&content);
521            Some((path, lang, content, hash))
522        })
523        .collect();
524
525    let mut cached_count = 0usize;
526    let mut changed_count = 0usize;
527    let mut all_findings: Vec<Finding> = Vec::new();
528    let mut contributions: Vec<(PathBuf, FileSymbols)> = Vec::new();
529
530    struct FileWork {
531        path: PathBuf,
532        lang: SupportLang,
533        hash: String,
534    }
535
536    let mut to_scan: Vec<FileWork> = Vec::new();
537
538    for (path, lang, _content, hash) in file_contents.iter() {
539        if !rules_changed {
540            if let Some(entry) = store.get(hash) {
541                cached_count += 1;
542                all_findings.extend(entry.findings);
543                if collect_symbols {
544                    contributions.push((path.clone(), entry.symbols));
545                }
546                continue;
547            }
548        }
549        changed_count += 1;
550        to_scan.push(FileWork {
551            path: path.clone(),
552            lang: *lang,
553            hash: hash.clone(),
554        });
555    }
556
557    let scanned: Vec<(PathBuf, String, FileScan)> = to_scan
558        .par_iter()
559        .map(|work| {
560            let scan = scan_file(&work.path, work.lang, compiled, collect_symbols);
561            (work.path.clone(), work.hash.clone(), scan)
562        })
563        .collect();
564
565    for (path, hash, scan) in scanned {
566        let entry = CacheEntry {
567            findings: scan.findings,
568            symbols: scan.symbols,
569        };
570        store.put(&hash, &entry).ok();
571        all_findings.extend(entry.findings);
572        if collect_symbols {
573            contributions.push((path, entry.symbols));
574        }
575    }
576
577    // Cross-file evaluation itself is never cached: it re-runs on every scan
578    // from the assembled index.
579    append_cross_file(&mut all_findings, compiled, run_cross_file, &contributions);
580
581    if prune {
582        let current_hashes: Vec<String> =
583            file_contents.iter().map(|(_, _, _, h)| h.clone()).collect();
584        store.cleanup(&current_hashes).ok();
585    }
586
587    normalize_findings(&mut all_findings);
588
589    let (errors, warnings) = count_severities(&all_findings);
590
591    let files_scanned = cached_count + changed_count;
592
593    ScanResult {
594        findings: all_findings,
595        stats: ScanStats {
596            errors,
597            warnings,
598            total: errors + warnings,
599            files_scanned,
600            baseline_filtered: 0,
601            diff_base: None,
602            files_changed: None,
603        },
604        cache_stats: Some(CacheStats {
605            cached: cached_count,
606            changed: changed_count,
607        }),
608    }
609}
610
611/// Scan the given paths for rule violations.
612pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
613    let compiled = compile_rules(rules)?;
614    let ignores = build_glob_set(&config.scan.ignores)?;
615    Ok(scan_collected(walk_files(paths, &ignores), &compiled, true))
616}
617
618/// Scan with file-level caching. Files whose content hash matches a cached
619/// entry (and whose rules have not changed) return cached findings without
620/// reparsing.
621///
622/// `cache_dir` is the directory where cache files are stored. When a custom
623/// cache directory is specified (via `--cache-dir`, `SLOPGUARD_CACHE_DIR`,
624/// or `scan.cache_dir` in config), pass it directly. Otherwise pass the
625/// project root and use `CacheStore::new` which appends `.slopguard-cache`.
626pub fn scan_cached(
627    paths: &[PathBuf],
628    rules: &[Rule],
629    config: &Config,
630    cache_dir: &Path,
631) -> Result<ScanResult, ScanError> {
632    let compiled = compile_rules(rules)?;
633    let ignores = build_glob_set(&config.scan.ignores)?;
634    Ok(scan_collected_cached(
635        walk_files(paths, &ignores),
636        &compiled,
637        rules,
638        cache_dir,
639        true,
640        true,
641    ))
642}
643
644/// Scan an explicit list of files instead of walking directories.
645///
646/// Used by `--diff`, where git already produced the exact file set. Paths that
647/// no longer exist or that slopguard cannot parse are silently skipped.
648///
649/// Cross-file rules are not evaluated here. `--diff` sees only the changed
650/// files, so the project index would be incomplete and its impl counts wrong.
651/// Symbol contributions are still collected and cached, so a later full scan is
652/// not penalised.
653pub fn scan_files(
654    files: &[PathBuf],
655    rules: &[Rule],
656    config: &Config,
657) -> Result<ScanResult, ScanError> {
658    let compiled = compile_rules(rules)?;
659    let ignores = build_glob_set(&config.scan.ignores)?;
660    Ok(scan_collected(
661        explicit_files(files, &ignores),
662        &compiled,
663        false,
664    ))
665}
666
667/// Cached variant of [`scan_files`]. Cache pruning is skipped: a partial scan
668/// must not evict the entries of files it did not look at.
669///
670/// Cross-file rules are not evaluated here. `--diff` sees only the changed
671/// files, so the project index would be incomplete and its impl counts wrong.
672/// Symbol contributions are still collected and cached, so a later full scan is
673/// not penalised.
674pub fn scan_files_cached(
675    files: &[PathBuf],
676    rules: &[Rule],
677    config: &Config,
678    cache_dir: &Path,
679) -> Result<ScanResult, ScanError> {
680    let compiled = compile_rules(rules)?;
681    let ignores = build_glob_set(&config.scan.ignores)?;
682    Ok(scan_collected_cached(
683        explicit_files(files, &ignores),
684        &compiled,
685        rules,
686        cache_dir,
687        false,
688        false,
689    ))
690}
691
692#[cfg(test)]
693mod tests {
694    use std::fs::{create_dir, write};
695
696    use tempfile::tempdir;
697
698    use super::*;
699    use crate::rule::{load_builtin_rules, parse_rule, RuleId};
700
701    fn unwrap_rule() -> Rule {
702        parse_rule(
703            r#"
704id: test-unwrap
705language: rust
706severity: error
707category: correctness
708message: ".unwrap() forbidden"
709rule:
710  kind: call_expression
711  regex: '\.unwrap\(\)\s*$'
712"#,
713        )
714        .unwrap()
715    }
716
717    fn scan_dir(dir: &Path, rules: &[Rule]) -> ScanResult {
718        scan(&[dir.to_path_buf()], rules, &Config::default()).unwrap()
719    }
720
721    #[test]
722    fn scan_finds_unwrap_violation() {
723        let dir = tempdir().unwrap();
724        write(
725            dir.path().join("main.rs"),
726            "fn main() {\n    foo().unwrap();\n}\n",
727        )
728        .unwrap();
729        let result = scan_dir(dir.path(), &[unwrap_rule()]);
730        assert_eq!(result.findings.len(), 1);
731        let f = &result.findings[0];
732        assert_eq!(f.rule_id, RuleId::from("test-unwrap"));
733        assert_eq!(f.severity, Severity::Error);
734        assert_eq!(f.line, 2);
735        assert!(f.matched_text.contains("unwrap()"));
736    }
737
738    #[test]
739    fn scan_clean_file_no_findings() {
740        let dir = tempdir().unwrap();
741        write(dir.path().join("main.rs"), "fn main() { let x = foo(); }\n").unwrap();
742        let result = scan_dir(dir.path(), &[unwrap_rule()]);
743        assert_eq!(result.findings.len(), 0);
744        assert_eq!(result.stats.files_scanned, 1);
745    }
746
747    #[test]
748    fn scan_unsupported_language_ignored() {
749        let dir = tempdir().unwrap();
750        write(dir.path().join("script.py"), "x.unwrap()\n").unwrap();
751        write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
752        let result = scan_dir(dir.path(), &[unwrap_rule()]);
753        assert_eq!(result.findings.len(), 0);
754        assert_eq!(result.stats.files_scanned, 1);
755    }
756
757    #[test]
758    fn scan_explicit_file_path() {
759        let dir = tempdir().unwrap();
760        let file = dir.path().join("main.rs");
761        write(&file, "fn main() { foo().unwrap(); }\n").unwrap();
762        let result = scan(&[file], &[unwrap_rule()], &Config::default()).unwrap();
763        assert_eq!(result.findings.len(), 1);
764        assert_eq!(result.stats.files_scanned, 1);
765    }
766
767    #[test]
768    fn rule_ignores_glob_respected() {
769        let dir = tempdir().unwrap();
770        let tests_dir = dir.path().join("tests");
771        create_dir(&tests_dir).unwrap();
772        write(
773            tests_dir.join("test_main.rs"),
774            "fn t() { foo().unwrap(); }\n",
775        )
776        .unwrap();
777        write(
778            dir.path().join("main.rs"),
779            "fn main() { foo().unwrap(); }\n",
780        )
781        .unwrap();
782        let rule = parse_rule(
783            r#"
784id: ign
785language: rust
786severity: error
787category: correctness
788message: x
789rule:
790  kind: call_expression
791  regex: '\.unwrap\(\)\s*$'
792ignores:
793  - "**/tests/**"
794"#,
795        )
796        .unwrap();
797        let result = scan_dir(dir.path(), &[rule]);
798        assert_eq!(result.findings.len(), 1);
799        assert!(result.findings[0]
800            .file
801            .to_string_lossy()
802            .contains("main.rs"));
803    }
804
805    #[test]
806    fn config_ignores_respected() {
807        let dir = tempdir().unwrap();
808        let gen = dir.path().join("generated");
809        create_dir(&gen).unwrap();
810        write(gen.join("out.rs"), "fn g() { foo().unwrap(); }\n").unwrap();
811        write(
812            dir.path().join("main.rs"),
813            "fn main() { foo().unwrap(); }\n",
814        )
815        .unwrap();
816        let mut cfg = Config::default();
817        cfg.scan.ignores = vec!["**/generated/**".to_string()];
818        let result = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap();
819        assert_eq!(result.stats.files_scanned, 1);
820        assert_eq!(result.findings.len(), 1);
821    }
822
823    #[test]
824    fn config_invalid_glob_is_an_error() {
825        let dir = tempdir().unwrap();
826        let mut cfg = Config::default();
827        cfg.scan.ignores = vec!["[".to_string()];
828        let err = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap_err();
829        assert!(matches!(err, ScanError::Glob(_)), "{err:?}");
830    }
831
832    #[test]
833    fn rule_without_kind_fails_to_compile() {
834        let dir = tempdir().unwrap();
835        let rule = parse_rule(
836            r#"
837id: no-kind
838language: rust
839severity: error
840category: correctness
841message: x
842rule:
843  regex: 'unwrap'
844"#,
845        )
846        .unwrap();
847        let err = scan(&[dir.path().to_path_buf()], &[rule], &Config::default()).unwrap_err();
848        assert!(
849            matches!(&err, ScanError::RuleCompile { id, .. } if *id == RuleId::from("no-kind")),
850            "{err:?}"
851        );
852    }
853
854    #[test]
855    fn stats_counting_accurate() {
856        let dir = tempdir().unwrap();
857        write(
858            dir.path().join("a.rs"),
859            "fn f() { foo().unwrap(); bar().unwrap(); }\n",
860        )
861        .unwrap();
862        let result = scan_dir(dir.path(), &[unwrap_rule()]);
863        assert_eq!(result.stats.errors, 2);
864        assert_eq!(result.stats.warnings, 0);
865        assert_eq!(result.stats.total, 2);
866        assert_eq!(result.stats.files_scanned, 1);
867    }
868
869    #[test]
870    fn scan_empty_file() {
871        let dir = tempdir().unwrap();
872        write(dir.path().join("empty.rs"), "").unwrap();
873        let result = scan_dir(dir.path(), &[unwrap_rule()]);
874        assert_eq!(result.findings.len(), 0);
875        assert_eq!(result.stats.files_scanned, 1);
876    }
877
878    #[test]
879    fn scan_complex_rule() {
880        let rule = parse_rule(
881            r#"
882id: dbg
883language: rust
884severity: error
885category: security
886message: debug on secret
887rule:
888  kind: attribute_item
889  all:
890    - regex: 'derive\([^)]*Debug'
891    - precedes:
892        kind: struct_item
893        has:
894          kind: field_identifier
895          regex: '^(api_key|password|secret)$'
896          stopBy: end
897"#,
898        )
899        .unwrap();
900        let dir = tempdir().unwrap();
901        write(
902            dir.path().join("t.rs"),
903            "#[derive(Debug)]\nstruct Config {\n    api_key: String,\n}\n",
904        )
905        .unwrap();
906        let result = scan_dir(dir.path(), &[rule]);
907        assert_eq!(result.findings.len(), 1);
908        assert_eq!(result.findings[0].rule_id, RuleId::from("dbg"));
909    }
910
911    #[test]
912    fn typescript_rule_applies_to_tsx_files() {
913        let rule = parse_rule(
914            r#"
915id: ts-any
916language: typescript
917severity: warning
918category: slop
919message: no any
920rule:
921  kind: predefined_type
922  regex: '^any$'
923"#,
924        )
925        .unwrap();
926        let dir = tempdir().unwrap();
927        write(dir.path().join("a.ts"), "let x: any = 1;\n").unwrap();
928        write(dir.path().join("b.tsx"), "let y: any = 2;\n").unwrap();
929        let result = scan_dir(dir.path(), &[rule]);
930        assert_eq!(result.stats.files_scanned, 2);
931        assert_eq!(result.stats.warnings, 2);
932    }
933
934    #[test]
935    fn scan_with_disable_comment_filters_findings() {
936        let dir = tempdir().unwrap();
937        write(
938            dir.path().join("main.rs"),
939            "fn main() {\n    // slopguard-disable-next-line\n    foo().unwrap();\n    bar().unwrap();\n}\n",
940        )
941        .unwrap();
942        let result = scan_dir(dir.path(), &[unwrap_rule()]);
943        assert_eq!(
944            result.findings.len(),
945            1,
946            "only the non-disabled unwrap should remain"
947        );
948        assert_eq!(result.findings[0].line, 4);
949    }
950
951    #[test]
952    fn scan_with_disable_rule_id_filters_only_that_rule() {
953        let dir = tempdir().unwrap();
954        write(
955            dir.path().join("main.rs"),
956            "fn main() {\n    // slopguard-disable-next-line test-unwrap\n    foo().unwrap();\n}\n",
957        )
958        .unwrap();
959        let result = scan_dir(dir.path(), &[unwrap_rule()]);
960        assert_eq!(result.findings.len(), 0);
961    }
962
963    fn unwrap_rule_skipping_test_code() -> Rule {
964        parse_rule(
965            r#"
966id: test-unwrap
967language: rust
968severity: error
969category: correctness
970message: ".unwrap() forbidden"
971rule:
972  kind: call_expression
973  regex: '\.unwrap\(\)\s*$'
974skip_test_code: true
975"#,
976        )
977        .unwrap()
978    }
979
980    #[test]
981    fn cfg_test_block_excluded_by_post_filter() {
982        let dir = tempdir().unwrap();
983        write(
984            dir.path().join("main.rs"),
985            "fn prod() {\n    foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n    fn test_it() {\n        bar().unwrap();\n    }\n}\n",
986        )
987        .unwrap();
988        let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
989        assert_eq!(
990            result.findings.len(),
991            1,
992            "should only find the prod unwrap, not the one inside #[cfg(test)]"
993        );
994        assert_eq!(result.findings[0].line, 2);
995    }
996
997    #[test]
998    fn cfg_test_kept_without_skip_test_code() {
999        let dir = tempdir().unwrap();
1000        write(
1001            dir.path().join("main.rs"),
1002            "fn prod() {\n    foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n    fn test_it() {\n        bar().unwrap();\n    }\n}\n",
1003        )
1004        .unwrap();
1005        let result = scan_dir(dir.path(), &[unwrap_rule()]);
1006        assert_eq!(
1007            result.findings.len(),
1008            2,
1009            "rule without skip_test_code should find both"
1010        );
1011    }
1012
1013    #[test]
1014    fn integration_test_file_excluded_by_skip_test_code() {
1015        let dir = tempdir().unwrap();
1016        let tests_dir = dir.path().join("tests");
1017        std::fs::create_dir(&tests_dir).unwrap();
1018        write(
1019            tests_dir.join("it.rs"),
1020            "fn check() {\n    foo().unwrap();\n}\n",
1021        )
1022        .unwrap();
1023        let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
1024        assert_eq!(
1025            result.findings.len(),
1026            0,
1027            "unwrap inside a tests/ integration file should be skipped"
1028        );
1029    }
1030
1031    #[test]
1032    fn integration_test_file_kept_without_skip_test_code() {
1033        let dir = tempdir().unwrap();
1034        let tests_dir = dir.path().join("tests");
1035        std::fs::create_dir(&tests_dir).unwrap();
1036        write(
1037            tests_dir.join("it.rs"),
1038            "fn check() {\n    foo().unwrap();\n}\n",
1039        )
1040        .unwrap();
1041        let result = scan_dir(dir.path(), &[unwrap_rule()]);
1042        assert_eq!(
1043            result.findings.len(),
1044            1,
1045            "rule without skip_test_code should still flag tests/ files"
1046        );
1047    }
1048
1049    #[test]
1050    fn test_crate_directory_excluded_by_skip_test_code() {
1051        let dir = tempdir().unwrap();
1052        let test_crate = dir
1053            .path()
1054            .join("crates")
1055            .join("integrations_tests")
1056            .join("src");
1057        std::fs::create_dir_all(&test_crate).unwrap();
1058        write(
1059            test_crate.join("api.rs"),
1060            "fn check() {\n    foo().unwrap();\n}\n",
1061        )
1062        .unwrap();
1063        let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
1064        assert_eq!(
1065            result.findings.len(),
1066            0,
1067            "unwrap inside a *_tests crate should be skipped"
1068        );
1069    }
1070
1071    #[test]
1072    fn test_crate_directory_kept_without_skip_test_code() {
1073        let dir = tempdir().unwrap();
1074        let test_crate = dir
1075            .path()
1076            .join("crates")
1077            .join("integrations_tests")
1078            .join("src");
1079        std::fs::create_dir_all(&test_crate).unwrap();
1080        write(
1081            test_crate.join("api.rs"),
1082            "fn check() {\n    foo().unwrap();\n}\n",
1083        )
1084        .unwrap();
1085        let result = scan_dir(dir.path(), &[unwrap_rule()]);
1086        assert_eq!(
1087            result.findings.len(),
1088            1,
1089            "rule without skip_test_code should still flag *_tests crate files"
1090        );
1091    }
1092
1093    #[test]
1094    fn all_builtin_rules_compile() {
1095        let rules = load_builtin_rules().unwrap();
1096        assert!(
1097            rules.len() >= 55,
1098            "expected at least 55 rules, got {}",
1099            rules.len()
1100        );
1101        let compiled = compile_rules(&rules).expect("all builtin rules should compile");
1102        let ast_rule_count = rules
1103            .iter()
1104            .filter(|r| !r.is_metric() && !r.is_cross_file())
1105            .count();
1106        assert_eq!(compiled.by_id.len(), ast_rule_count);
1107        assert!(
1108            !compiled.cross_file.is_empty(),
1109            "builtin rules should include cross-file rules"
1110        );
1111        assert!(
1112            !compiled.metrics.is_empty(),
1113            "builtin rules should include metric rules"
1114        );
1115    }
1116
1117    #[test]
1118    fn scan_files_only_scans_given_files() {
1119        let dir = tempdir().unwrap();
1120        let a = dir.path().join("a.rs");
1121        let b = dir.path().join("b.rs");
1122        write(&a, "fn a() {\n    foo().unwrap();\n}\n").unwrap();
1123        write(&b, "fn b() {\n    bar().unwrap();\n}\n").unwrap();
1124
1125        let result = scan_files(&[a], &[unwrap_rule()], &Config::default()).unwrap();
1126        assert_eq!(result.findings.len(), 1);
1127        assert_eq!(result.stats.files_scanned, 1);
1128        assert!(result.findings[0].file.to_string_lossy().contains("a.rs"));
1129    }
1130
1131    #[test]
1132    fn scan_files_skips_unsupported_language() {
1133        let dir = tempdir().unwrap();
1134        let py = dir.path().join("script.py");
1135        write(&py, "x.unwrap()\n").unwrap();
1136
1137        let result = scan_files(&[py], &[unwrap_rule()], &Config::default()).unwrap();
1138        assert_eq!(result.findings.len(), 0);
1139        assert_eq!(result.stats.files_scanned, 0);
1140    }
1141
1142    #[test]
1143    fn scan_files_skips_missing_file() {
1144        let dir = tempdir().unwrap();
1145        let missing = dir.path().join("gone.rs");
1146
1147        let result = scan_files(&[missing], &[unwrap_rule()], &Config::default()).unwrap();
1148        assert_eq!(result.findings.len(), 0);
1149        assert_eq!(result.stats.files_scanned, 0);
1150    }
1151
1152    #[test]
1153    fn scan_files_respects_config_ignores() {
1154        let dir = tempdir().unwrap();
1155        let gen = dir.path().join("generated");
1156        create_dir(&gen).unwrap();
1157        let out = gen.join("out.rs");
1158        write(&out, "fn g() { foo().unwrap(); }\n").unwrap();
1159
1160        let mut cfg = Config::default();
1161        cfg.scan.ignores = vec!["**/generated/**".to_string()];
1162        let result = scan_files(&[out], &[unwrap_rule()], &cfg).unwrap();
1163        assert_eq!(result.findings.len(), 0);
1164        assert_eq!(result.stats.files_scanned, 0);
1165    }
1166
1167    #[test]
1168    fn scan_files_cached_keeps_other_entries() {
1169        let src_dir = tempdir().unwrap();
1170        let cache_dir = tempdir().unwrap();
1171        let cache_path = cache_dir.path().join("cache");
1172        let a = src_dir.path().join("a.rs");
1173        let b = src_dir.path().join("b.rs");
1174        write(&a, "fn a() {\n    foo().unwrap();\n}\n").unwrap();
1175        write(&b, "fn b() {\n    bar().unwrap();\n}\n").unwrap();
1176
1177        let rules = [unwrap_rule()];
1178        let config = Config::default();
1179        let paths = [src_dir.path().to_path_buf()];
1180
1181        scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1182
1183        // A partial scan must not evict the entry of the file it skipped.
1184        scan_files_cached(&[a], &rules, &config, &cache_path).unwrap();
1185
1186        let result = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1187        let stats = result.cache_stats.as_ref().unwrap();
1188        assert_eq!(stats.cached, 2, "a partial scan must not prune");
1189        assert_eq!(stats.changed, 0);
1190    }
1191
1192    fn metric_rule(body: &str) -> Rule {
1193        parse_rule(body).unwrap()
1194    }
1195
1196    fn file_lines_rule() -> Rule {
1197        metric_rule(
1198            r#"
1199id: test-file-lines
1200language: rust
1201severity: warning
1202category: slop
1203metric: file_lines
1204threshold: 10
1205message: "File is too long"
1206"#,
1207        )
1208    }
1209
1210    /// `n` lines of trivial Rust, one statement per line.
1211    fn rust_lines(n: usize) -> String {
1212        (0..n).map(|i| format!("// line {i}\n")).collect()
1213    }
1214
1215    #[test]
1216    fn scan_finds_file_lines_violation() {
1217        let dir = tempdir().unwrap();
1218        write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1219        let result = scan_dir(dir.path(), &[file_lines_rule()]);
1220        assert_eq!(result.findings.len(), 1);
1221        let f = &result.findings[0];
1222        assert_eq!(f.rule_id, RuleId::from("test-file-lines"));
1223        assert_eq!(f.line, 1);
1224        assert_eq!(f.column, 1);
1225        assert_eq!(f.end_line, 1);
1226        assert_eq!(f.end_column, 1);
1227        assert_eq!(f.matched_text, "12 lines");
1228    }
1229
1230    #[test]
1231    fn metric_message_interpolates_value() {
1232        let dir = tempdir().unwrap();
1233        write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1234        let rule = metric_rule(
1235            r#"
1236id: test-interpolate
1237language: rust
1238severity: warning
1239category: slop
1240metric: file_lines
1241threshold: 10
1242message: "File exceeds 10 lines ($value lines)"
1243"#,
1244        );
1245        let result = scan_dir(dir.path(), &[rule]);
1246        assert_eq!(result.findings.len(), 1);
1247        let message = &result.findings[0].message;
1248        assert!(message.contains("(12 lines)"), "got: {message}");
1249        assert!(!message.contains("$value"), "got: {message}");
1250    }
1251
1252    #[test]
1253    fn metric_rule_below_threshold_no_finding() {
1254        let dir = tempdir().unwrap();
1255        write(dir.path().join("exact.rs"), rust_lines(10)).unwrap();
1256        let result = scan_dir(dir.path(), &[file_lines_rule()]);
1257        assert_eq!(
1258            result.findings.len(),
1259            0,
1260            "a file of exactly the threshold length must not fire"
1261        );
1262    }
1263
1264    #[test]
1265    fn metric_comment_ratio_finding() {
1266        let dir = tempdir().unwrap();
1267        write(
1268            dir.path().join("commented.rs"),
1269            "// a\n// b\n// c\n// d\nfn f() {}\nfn g() {}\nfn h() {}\n",
1270        )
1271        .unwrap();
1272        let rule = metric_rule(
1273            r#"
1274id: test-comment-ratio
1275language: rust
1276severity: warning
1277category: slop
1278metric: comment_ratio
1279threshold: 0.4
1280message: "Too many comments ($value)"
1281"#,
1282        );
1283        let result = scan_dir(dir.path(), &[rule]);
1284        assert_eq!(result.findings.len(), 1);
1285        assert!(
1286            result.findings[0].matched_text.ends_with("comment ratio"),
1287            "got: {}",
1288            result.findings[0].matched_text
1289        );
1290    }
1291
1292    #[test]
1293    fn metric_rule_respects_ignores_glob() {
1294        let dir = tempdir().unwrap();
1295        let gen = dir.path().join("generated");
1296        create_dir(&gen).unwrap();
1297        write(gen.join("out.rs"), rust_lines(12)).unwrap();
1298        write(dir.path().join("main.rs"), rust_lines(12)).unwrap();
1299        let rule = metric_rule(
1300            r#"
1301id: test-metric-ignores
1302language: rust
1303severity: warning
1304category: slop
1305metric: file_lines
1306threshold: 10
1307message: "too long"
1308ignores:
1309  - "**/generated/**"
1310"#,
1311        );
1312        let result = scan_dir(dir.path(), &[rule]);
1313        assert_eq!(result.findings.len(), 1);
1314        assert!(result.findings[0]
1315            .file
1316            .to_string_lossy()
1317            .contains("main.rs"));
1318    }
1319
1320    #[test]
1321    fn metric_rule_respects_files_glob() {
1322        let dir = tempdir().unwrap();
1323        let src = dir.path().join("src");
1324        create_dir(&src).unwrap();
1325        write(src.join("lib.rs"), rust_lines(12)).unwrap();
1326        write(dir.path().join("build.rs"), rust_lines(12)).unwrap();
1327        let rule = metric_rule(
1328            r#"
1329id: test-metric-files
1330language: rust
1331severity: warning
1332category: slop
1333metric: file_lines
1334threshold: 10
1335message: "too long"
1336files:
1337  - "**/src/**/*.rs"
1338"#,
1339        );
1340        let result = scan_dir(dir.path(), &[rule]);
1341        assert_eq!(result.findings.len(), 1);
1342        assert!(result.findings[0].file.to_string_lossy().contains("lib.rs"));
1343    }
1344
1345    #[test]
1346    fn metric_rule_skip_test_code_skips_test_files() {
1347        let dir = tempdir().unwrap();
1348        let tests_dir = dir.path().join("tests");
1349        create_dir(&tests_dir).unwrap();
1350        write(tests_dir.join("it.rs"), rust_lines(12)).unwrap();
1351
1352        let skipping = metric_rule(
1353            r#"
1354id: test-metric-skip
1355language: rust
1356severity: warning
1357category: slop
1358metric: file_lines
1359threshold: 10
1360message: "too long"
1361skip_test_code: true
1362"#,
1363        );
1364        let skipped = scan_dir(dir.path(), &[skipping]);
1365        assert_eq!(skipped.findings.len(), 0);
1366        let reported = scan_dir(dir.path(), &[file_lines_rule()]);
1367        assert_eq!(reported.findings.len(), 1);
1368    }
1369
1370    #[test]
1371    fn metric_only_ruleset_still_scans() {
1372        let dir = tempdir().unwrap();
1373        write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1374        let result = scan_dir(dir.path(), &[file_lines_rule()]);
1375        assert_eq!(
1376            result.findings.len(),
1377            1,
1378            "a rule set with only metric rules must still scan files"
1379        );
1380    }
1381
1382    #[test]
1383    fn metric_and_ast_rules_together() {
1384        let dir = tempdir().unwrap();
1385        let mut source = String::from("fn main() {\n    foo().unwrap();\n}\n");
1386        source.push_str(&rust_lines(12));
1387        write(dir.path().join("main.rs"), source).unwrap();
1388
1389        let result = scan_dir(dir.path(), &[unwrap_rule(), file_lines_rule()]);
1390        assert_eq!(result.findings.len(), 2);
1391        let first = &result.findings[0];
1392        assert_eq!(first.rule_id, RuleId::from("test-file-lines"));
1393        assert_eq!(first.line, 1);
1394        let second = &result.findings[1];
1395        assert_eq!(second.rule_id, RuleId::from("test-unwrap"));
1396    }
1397
1398    #[test]
1399    fn metric_rule_wrong_language_not_applied() {
1400        let dir = tempdir().unwrap();
1401        write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1402        let rule = metric_rule(
1403            r#"
1404id: test-metric-ts
1405language: typescript
1406severity: warning
1407category: slop
1408metric: file_lines
1409threshold: 10
1410message: "too long"
1411"#,
1412        );
1413        let result = scan_dir(dir.path(), &[rule]);
1414        assert_eq!(result.findings.len(), 0);
1415    }
1416
1417    #[test]
1418    fn metric_findings_are_cached() {
1419        let src_dir = tempdir().unwrap();
1420        let cache_dir = tempdir().unwrap();
1421        let cache_path = cache_dir.path().join("cache");
1422        write(src_dir.path().join("big.rs"), rust_lines(12)).unwrap();
1423
1424        let rules = [file_lines_rule()];
1425        let config = Config::default();
1426        let paths = [src_dir.path().to_path_buf()];
1427
1428        let first = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1429        assert_eq!(first.findings.len(), 1);
1430        assert_eq!(first.cache_stats.as_ref().unwrap().changed, 1);
1431
1432        let second = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1433        assert_eq!(second.cache_stats.as_ref().unwrap().cached, 1);
1434        assert_eq!(second.findings.len(), 1);
1435        assert_eq!(second.findings[0].matched_text, "12 lines");
1436    }
1437
1438    #[test]
1439    fn scan_with_cache() {
1440        let src_dir = tempdir().unwrap();
1441        let cache_dir = tempdir().unwrap();
1442        let cache_path = cache_dir.path().join("cache");
1443        write(
1444            src_dir.path().join("main.rs"),
1445            "fn main() {\n    foo().unwrap();\n}\n",
1446        )
1447        .unwrap();
1448
1449        let rules = [unwrap_rule()];
1450        let config = Config::default();
1451
1452        let result1 = scan_cached(
1453            &[src_dir.path().to_path_buf()],
1454            &rules,
1455            &config,
1456            &cache_path,
1457        )
1458        .unwrap();
1459
1460        assert_eq!(result1.findings.len(), 1);
1461        let cache_stats1 = result1.cache_stats.as_ref().unwrap();
1462        assert_eq!(cache_stats1.cached, 0, "first scan: nothing cached");
1463        assert_eq!(cache_stats1.changed, 1, "first scan: one file scanned");
1464
1465        let result2 = scan_cached(
1466            &[src_dir.path().to_path_buf()],
1467            &rules,
1468            &config,
1469            &cache_path,
1470        )
1471        .unwrap();
1472
1473        assert_eq!(result2.findings.len(), 1);
1474        let cache_stats2 = result2.cache_stats.as_ref().unwrap();
1475        assert_eq!(cache_stats2.cached, 1, "second scan: one file from cache");
1476        assert_eq!(cache_stats2.changed, 0, "second scan: nothing changed");
1477    }
1478
1479    fn single_impl_trait_rule() -> Rule {
1480        parse_rule(
1481            r#"
1482id: no-single-impl-trait
1483language: rust
1484severity: warning
1485category: slop
1486cross_file: single_impl_trait
1487message: "Trait with a single implementation in the project."
1488skip_test_code: true
1489"#,
1490        )
1491        .unwrap()
1492    }
1493
1494    #[test]
1495    fn cross_file_single_impl_trait_reported() {
1496        let dir = tempdir().unwrap();
1497        write(
1498            dir.path().join("repo.rs"),
1499            "pub trait Repository {\n    fn get(&self);\n}\n",
1500        )
1501        .unwrap();
1502        write(
1503            dir.path().join("pg.rs"),
1504            "pub struct Pg;\n\nimpl Repository for Pg {\n    fn get(&self) {}\n}\n",
1505        )
1506        .unwrap();
1507
1508        let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1509        assert_eq!(result.findings.len(), 1);
1510        let f = &result.findings[0];
1511        assert_eq!(f.rule_id, RuleId::from("no-single-impl-trait"));
1512        assert!(f.file.to_string_lossy().ends_with("repo.rs"));
1513        assert_eq!(f.line, 1);
1514        assert_eq!(f.matched_text, "pub trait Repository");
1515    }
1516
1517    #[test]
1518    fn cross_file_two_impls_not_reported() {
1519        let dir = tempdir().unwrap();
1520        write(dir.path().join("repo.rs"), "pub trait Repository {}\n").unwrap();
1521        write(dir.path().join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1522        write(dir.path().join("mem.rs"), "impl Repository for Mem {}\n").unwrap();
1523
1524        let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1525        assert_eq!(result.findings.len(), 0);
1526    }
1527
1528    #[test]
1529    fn cross_file_no_impl_not_reported() {
1530        let dir = tempdir().unwrap();
1531        write(dir.path().join("repo.rs"), "pub trait Repository {}\n").unwrap();
1532
1533        let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1534        assert_eq!(result.findings.len(), 0);
1535    }
1536
1537    #[test]
1538    fn cross_file_blanket_impl_not_reported() {
1539        let dir = tempdir().unwrap();
1540        write(dir.path().join("desc.rs"), "pub trait Describe {}\n").unwrap();
1541        write(dir.path().join("all.rs"), "impl<T> Describe for T {}\n").unwrap();
1542
1543        let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1544        assert_eq!(result.findings.len(), 0);
1545    }
1546
1547    #[test]
1548    fn cross_file_duplicate_trait_name_not_reported() {
1549        let dir = tempdir().unwrap();
1550        write(dir.path().join("a.rs"), "pub trait Repository {}\n").unwrap();
1551        write(dir.path().join("b.rs"), "pub trait Repository {}\n").unwrap();
1552        write(dir.path().join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1553
1554        let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1555        assert_eq!(result.findings.len(), 0);
1556    }
1557
1558    #[test]
1559    fn cross_file_disable_comment_on_declaration() {
1560        let dir = tempdir().unwrap();
1561        write(
1562            dir.path().join("repo.rs"),
1563            "// slopguard-disable-next-line no-single-impl-trait\npub trait Repository {}\n",
1564        )
1565        .unwrap();
1566        write(dir.path().join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1567
1568        let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1569        assert_eq!(
1570            result.findings.len(),
1571            0,
1572            "a disable comment above the declaration must suppress the finding"
1573        );
1574    }
1575
1576    #[test]
1577    fn cross_file_cfg_test_mock_counts_as_second_impl() {
1578        let dir = tempdir().unwrap();
1579        write(dir.path().join("repo.rs"), "pub trait Repository {}\n").unwrap();
1580        write(dir.path().join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1581        write(
1582            dir.path().join("mock.rs"),
1583            "#[cfg(test)]\nmod tests {\n    struct MockRepo;\n    impl Repository for MockRepo {}\n}\n",
1584        )
1585        .unwrap();
1586
1587        let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1588        assert_eq!(
1589            result.findings.len(),
1590            0,
1591            "a test mock counts as a second implementation"
1592        );
1593    }
1594
1595    #[test]
1596    fn cross_file_declaration_in_test_path_not_reported() {
1597        let dir = tempdir().unwrap();
1598        let tests_dir = dir.path().join("tests");
1599        create_dir(&tests_dir).unwrap();
1600        write(tests_dir.join("support.rs"), "pub trait Repository {}\n").unwrap();
1601        write(tests_dir.join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1602
1603        let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1604        assert_eq!(result.findings.len(), 0);
1605    }
1606
1607    #[test]
1608    fn cross_file_not_run_for_scan_files() {
1609        let dir = tempdir().unwrap();
1610        let repo = dir.path().join("repo.rs");
1611        let pg = dir.path().join("pg.rs");
1612        write(&repo, "pub trait Repository {}\n").unwrap();
1613        write(&pg, "impl Repository for Pg {}\n").unwrap();
1614
1615        let rules = [single_impl_trait_rule()];
1616        let partial = scan_files(&[repo, pg], &rules, &Config::default()).unwrap();
1617        assert_eq!(
1618            partial.findings.len(),
1619            0,
1620            "an explicit file list has an incomplete project index"
1621        );
1622
1623        let full = scan_dir(dir.path(), &rules);
1624        assert_eq!(full.findings.len(), 1);
1625    }
1626
1627    #[test]
1628    fn cross_file_survives_cache_round_trip() {
1629        let src_dir = tempdir().unwrap();
1630        let cache_dir = tempdir().unwrap();
1631        let cache_path = cache_dir.path().join("cache");
1632        write(src_dir.path().join("repo.rs"), "pub trait Repository {}\n").unwrap();
1633        write(src_dir.path().join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1634
1635        let rules = [single_impl_trait_rule()];
1636        let config = Config::default();
1637        let paths = [src_dir.path().to_path_buf()];
1638
1639        let first = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1640        assert_eq!(first.findings.len(), 1);
1641        assert_eq!(first.cache_stats.as_ref().unwrap().changed, 2);
1642
1643        let second = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1644        let stats = second.cache_stats.as_ref().unwrap();
1645        assert_eq!(stats.cached, 2, "both files should come from the cache");
1646        assert_eq!(stats.changed, 0);
1647        assert_eq!(
1648            second.findings.len(),
1649            1,
1650            "symbols must survive the cache round trip"
1651        );
1652    }
1653
1654    #[test]
1655    fn cross_file_rule_with_no_ast_rules_still_parses_files() {
1656        let dir = tempdir().unwrap();
1657        write(dir.path().join("repo.rs"), "pub trait Repository {}\n").unwrap();
1658        write(dir.path().join("pg.rs"), "impl Repository for Pg {}\n").unwrap();
1659
1660        let result = scan_dir(dir.path(), &[single_impl_trait_rule()]);
1661        assert_eq!(
1662            result.findings.len(),
1663            1,
1664            "a cross-file-only ruleset must still parse every file"
1665        );
1666    }
1667}