Skip to main content

slopguard_core/rule/
load.rs

1//! Rule loading: the embedded builtin ruleset, custom directories and external
2//! sources, their activation under a config, and whole-file test fixtures.
3
4use std::collections::HashSet;
5use std::fs::read_to_string;
6use std::io::Error as IoError;
7use std::path::{Component, Path, PathBuf};
8use std::str::from_utf8;
9
10use ignore::WalkBuilder;
11use slopguard_rules::BuiltinRules;
12
13use super::{parse_rule, Category, Language, Rule, RuleError, RuleId};
14use crate::config::Config;
15use crate::source::{ResolvedSource, RuleOrigin};
16
17/// Read a whole-file test fixture referenced by `tests.should_match_files` or
18/// `tests.should_not_match_files`. Builtin rules resolve against the embedded
19/// ruleset; custom rules resolve against the directory they were loaded from.
20pub fn read_fixture(rule: &Rule, relative: &str) -> Result<String, RuleError> {
21    let path = Path::new(relative);
22    let traverses = path.components().any(|c| matches!(c, Component::ParentDir));
23    if path.is_absolute() || traverses {
24        return Err(RuleError::InvalidFixturePath {
25            id: rule.id.clone(),
26            path: relative.to_string(),
27        });
28    }
29
30    match &rule.source_dir {
31        Some(dir) => {
32            let full = dir.join(path);
33            read_to_string(&full).map_err(|e| {
34                if e.kind() == std::io::ErrorKind::NotFound {
35                    RuleError::FixtureNotFound {
36                        id: rule.id.clone(),
37                        path: relative.to_string(),
38                    }
39                } else {
40                    RuleError::io(&full, e)
41                }
42            })
43        }
44        None => {
45            let file = BuiltinRules::get(relative).ok_or_else(|| RuleError::FixtureNotFound {
46                id: rule.id.clone(),
47                path: relative.to_string(),
48            })?;
49            from_utf8(&file.data)
50                .map(str::to_string)
51                .map_err(|e| RuleError::Utf8 {
52                    path: relative.to_string(),
53                    source: e,
54                })
55        }
56    }
57}
58
59/// Parse a rule and fill in its category from the ruleset-relative path when
60/// the YAML omits it.
61fn parse_rule_at(yaml: &str, relative_path: &Path) -> Result<Rule, RuleError> {
62    let mut rule = parse_rule(yaml)?;
63    if rule.category.is_none() {
64        rule.category = Category::from_rule_path(relative_path);
65    }
66    Ok(rule)
67}
68
69pub(super) fn is_rule_file(path: &Path) -> bool {
70    matches!(
71        path.extension().and_then(|e| e.to_str()),
72        Some("yml" | "yaml")
73    )
74}
75
76/// Load all builtin rules embedded via rust-embed.
77pub fn load_builtin_rules() -> Result<Vec<Rule>, RuleError> {
78    BuiltinRules::iter()
79        .filter(|path| is_rule_file(Path::new(path.as_ref())))
80        .filter_map(|path| BuiltinRules::get(&path).map(|file| (path, file)))
81        .map(|(path, file)| {
82            let yaml = from_utf8(&file.data).map_err(|e| RuleError::Utf8 {
83                path: path.to_string(),
84                source: e,
85            })?;
86            parse_rule_at(yaml, Path::new(path.as_ref()))
87        })
88        .collect()
89}
90
91/// Load every rule YAML found under `dir`, stamping each with `dir` as its
92/// fixture root and `origin` as its provenance. Subdirectories are searched
93/// recursively.
94fn load_rules_in_dir(dir: &Path, origin: &RuleOrigin) -> Result<Vec<Rule>, RuleError> {
95    let mut rules = Vec::new();
96    // Rule directories are plain file trees: gitignore and hidden-file
97    // filtering would silently drop rules.
98    for entry in WalkBuilder::new(dir).standard_filters(false).build() {
99        let entry = entry.map_err(|e| RuleError::io(dir, IoError::other(e)))?;
100        let path = entry.path();
101        let is_file = entry.file_type().is_some_and(|t| t.is_file());
102        if !is_file || !is_rule_file(path) {
103            continue;
104        }
105        let yaml = read_to_string(path).map_err(|e| RuleError::io(path, e))?;
106        let relative = path.strip_prefix(dir).unwrap_or(path);
107        let mut rule = parse_rule_at(&yaml, relative)?;
108        rule.source_dir = Some(dir.to_path_buf());
109        rule.origin = origin.clone();
110        rules.push(rule);
111    }
112    Ok(rules)
113}
114
115/// Load rules from custom directories on the filesystem. Missing directories
116/// are skipped; subdirectories are searched recursively.
117pub fn load_custom_rules(dirs: &[PathBuf]) -> Result<Vec<Rule>, RuleError> {
118    let mut rules = Vec::new();
119    for dir in dirs.iter().filter(|dir| dir.is_dir()) {
120        let origin = RuleOrigin::Local { path: dir.clone() };
121        rules.extend(load_rules_in_dir(dir, &origin)?);
122    }
123    Ok(rules)
124}
125
126/// Load rules from resolved external sources (git clones and local paths),
127/// stamping each rule with the source's provenance. A source directory that is
128/// missing (e.g. a git `path` sub-directory that does not exist) is skipped.
129pub fn load_source_rules(sources: &[ResolvedSource]) -> Result<Vec<Rule>, RuleError> {
130    let mut rules = Vec::new();
131    for source in sources.iter().filter(|s| s.dir.is_dir()) {
132        rules.extend(load_rules_in_dir(&source.dir, &source.origin)?);
133    }
134    Ok(rules)
135}
136
137/// Reject any external rule whose (id, language) collides with a builtin rule.
138/// External sources extend the ruleset; they must never shadow a builtin id.
139fn reject_builtin_id_reuse(external: &[Rule], builtin: &[Rule]) -> Result<(), RuleError> {
140    let builtin_ids: HashSet<(&RuleId, &Language)> =
141        builtin.iter().map(|r| (&r.id, &r.language)).collect();
142    for rule in external {
143        if builtin_ids.contains(&(&rule.id, &rule.language)) {
144            return Err(RuleError::SourceReusesBuiltinId {
145                id: rule.id.clone(),
146                origin: rule.origin.label(),
147            });
148        }
149    }
150    Ok(())
151}
152
153/// Whether a rule (builtin or external) takes part in a scan under `config`.
154///
155/// A rule listed in `rules.enable` is always active. Otherwise it must be
156/// enabled by default, belong to an active ruleset, and not be listed in
157/// `rules.disable`.
158pub fn is_rule_active(rule: &Rule, config: &Config) -> bool {
159    let listed = |ids: &[String]| ids.iter().any(|id| id == rule.id.as_str());
160    if listed(&config.rules.enable) {
161        return true;
162    }
163    let ruleset_on = match rule.effective_category() {
164        Category::Slop => config.rulesets.slop,
165        Category::Security => config.rulesets.security,
166        Category::Correctness => config.rulesets.correctness,
167    };
168    ruleset_on && rule.enabled && !listed(&config.rules.disable)
169}
170
171/// Load the non-builtin rules (custom directories + external sources) and
172/// reject any of them that reuses a builtin id: neither a custom directory nor
173/// a source may shadow a builtin rule. Custom rules come first, then source
174/// rules, matching the order both public loaders expose.
175fn load_external_rules(
176    config: &Config,
177    sources: &[ResolvedSource],
178    builtin: &[Rule],
179) -> Result<(Vec<Rule>, Vec<Rule>), RuleError> {
180    let custom = load_custom_rules(&config.rules.custom_dirs)?;
181    let from_sources = load_source_rules(sources)?;
182    reject_builtin_id_reuse(&custom, builtin)?;
183    reject_builtin_id_reuse(&from_sources, builtin)?;
184    Ok((custom, from_sources))
185}
186
187/// Load all effective rules based on config: builtin, custom rules from
188/// configured directories, and rules from resolved external sources, all
189/// filtered by [`is_rule_active`] (rulesets, `enabled: false`, and the
190/// enable/disable lists). An external rule reusing a builtin id is rejected
191/// before any filtering.
192pub fn load_effective_rules(
193    config: &Config,
194    sources: &[ResolvedSource],
195) -> Result<Vec<Rule>, RuleError> {
196    let builtin = load_builtin_rules()?;
197    let (custom, from_sources) = load_external_rules(config, sources, &builtin)?;
198
199    // Rules from `custom_dirs` are an explicit opt-in and are not gated by
200    // rulesets or `enabled`; builtin and source rules are.
201    let mut rules: Vec<Rule> = builtin
202        .into_iter()
203        .chain(from_sources)
204        .filter(|rule| is_rule_active(rule, config))
205        .collect();
206    rules.extend(custom);
207    validate_unique_ids(&rules)?;
208    Ok(rules)
209}
210
211/// Load all rules (builtin + custom + external sources) without filtering by
212/// activation status. Used by `explain` to look up any rule regardless of
213/// config, so an opt-in or disabled rule can still be explained.
214pub fn load_all_rules(config: &Config, sources: &[ResolvedSource]) -> Result<Vec<Rule>, RuleError> {
215    let builtin = load_builtin_rules()?;
216    let (custom, from_sources) = load_external_rules(config, sources, &builtin)?;
217
218    let mut rules = builtin;
219    rules.extend(custom);
220    rules.extend(from_sources);
221    Ok(rules)
222}
223
224/// Validate that all rule (id, language) pairs are unique. The same rule id
225/// may appear for different languages (e.g. `no-todo-fixme` for both Rust and
226/// TypeScript), so a single `rules.disable` entry covers both variants.
227pub fn validate_unique_ids(rules: &[Rule]) -> Result<(), RuleError> {
228    let mut seen = HashSet::new();
229    for rule in rules {
230        if !seen.insert((&rule.id, &rule.language)) {
231            return Err(RuleError::DuplicateId {
232                id: rule.id.clone(),
233            });
234        }
235    }
236    Ok(())
237}