Skip to main content

slopguard_core/rule/
mod.rs

1use std::collections::HashSet;
2use std::fmt;
3use std::fs::read_to_string;
4use std::io::Error as IoError;
5use std::path::{Component, Path, PathBuf};
6use std::str::{from_utf8, Utf8Error};
7
8use ast_grep_language::SupportLang;
9use ignore::WalkBuilder;
10use serde::{Deserialize, Serialize};
11use serde_yaml::Value;
12use slopguard_rules::BuiltinRules;
13use strum::{Display, EnumIter, EnumString};
14use thiserror::Error;
15
16use crate::cross_file::CrossFileKind;
17use crate::metric::Metric;
18use crate::resolution::ResolutionKind;
19use crate::source::{ResolvedSource, RuleOrigin};
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub struct RuleId(pub String);
23
24impl fmt::Display for RuleId {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        f.write_str(&self.0)
27    }
28}
29
30impl RuleId {
31    pub fn as_str(&self) -> &str {
32        &self.0
33    }
34}
35
36impl From<String> for RuleId {
37    fn from(s: String) -> Self {
38        Self(s)
39    }
40}
41
42impl From<&str> for RuleId {
43    fn from(s: &str) -> Self {
44        Self(s.to_string())
45    }
46}
47
48#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "lowercase")]
50#[strum(serialize_all = "lowercase")]
51pub enum Severity {
52    Error,
53    Warning,
54}
55
56#[derive(Debug, Display, EnumIter, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58#[strum(serialize_all = "lowercase")]
59pub enum Language {
60    Rust,
61    TypeScript,
62}
63
64impl Language {
65    /// The ast-grep languages a rule written for this language is compiled
66    /// against. TypeScript rules also cover TSX files.
67    pub fn ast_grep_langs(&self) -> &'static [SupportLang] {
68        match self {
69            Language::Rust => &[SupportLang::Rust],
70            Language::TypeScript => &[SupportLang::TypeScript, SupportLang::Tsx],
71        }
72    }
73}
74
75#[derive(Debug, Display, EnumString, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77#[strum(serialize_all = "lowercase")]
78pub enum Category {
79    Slop,
80    Security,
81    #[default]
82    Correctness,
83}
84
85impl Category {
86    /// Derive the category from the first directory of a rule path relative to
87    /// its ruleset root (e.g. `security/no-debug.yml`). Returns `None` when the
88    /// path has no directory or the directory is not a known category.
89    fn from_rule_path(path: &Path) -> Option<Self> {
90        match path.components().next()? {
91            Component::Normal(dir) => dir.to_str()?.parse().ok(),
92            _ => None,
93        }
94    }
95}
96
97/// One rewrite assertion for an autofixable rule: `slopguard test` applies the
98/// rule's `rewrite` to `before` and requires the result to equal `after`. This
99/// is what proves a `rewrite` is correct, alongside `should_match`.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct FixCase {
102    pub before: String,
103    pub after: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct RuleTests {
108    #[serde(default)]
109    pub should_match: Vec<String>,
110    #[serde(default)]
111    pub should_not_match: Vec<String>,
112    /// Rewrite assertions checked by `slopguard test` for autofixable rules.
113    /// Each case's `before` is rewritten with the rule's `rewrite` and must
114    /// equal `after`. Ignored for rules that are not autofixable.
115    #[serde(default)]
116    pub should_fix: Vec<FixCase>,
117    /// Whole-file fixtures for metric rules, resolved against the embedded
118    /// ruleset root (builtin rules) or the custom rule directory the rule was
119    /// loaded from. A metric cannot be measured on a snippet.
120    #[serde(default)]
121    pub should_match_files: Vec<String>,
122    #[serde(default)]
123    pub should_not_match_files: Vec<String>,
124}
125
126/// How the `reason` (the finding's `note`) is produced when an `ai_check` rule
127/// fires. See [`AiCheck::reason`].
128#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
129#[serde(rename_all = "lowercase")]
130#[strum(serialize_all = "lowercase")]
131pub enum ReasonMode {
132    /// Use the rule's static `note` as the reason. No generative LLM call.
133    #[default]
134    Static,
135    /// Escalate to the generative LLM to write a per-instance reason. Reserved
136    /// for relational rules whose message depends on the specific match.
137    Generated,
138}
139
140/// Optional AI confirmation step for a rule. When present, the rule's `rule`
141/// AST pattern acts as a cheap pre-filter: matches become candidates that an
142/// LLM (or the System One classifier) must confirm before they are reported.
143/// The AST layer never executes this field; the AI pipeline in `slopguard-ai`
144/// reads it.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct AiCheck {
147    /// Prompt template. Double use: the generative LLM prompt (variables
148    /// `{{code}}`, `{{filename}}`, `{{rule_context}}`), and the `instructions`
149    /// of the System One classifier's noul question.
150    pub prompt: String,
151    /// Model override for this rule. Falls back to `[ai].model` in config.
152    #[serde(default)]
153    pub model: Option<String>,
154    /// How the finding's reason is produced: a `static` note (default) or a
155    /// per-instance `generated` note written by the LLM.
156    #[serde(default)]
157    pub reason: ReasonMode,
158    /// Per-rule classifier probability threshold, overriding
159    /// `[ai.classifier].threshold`. Fires when `p >= threshold`.
160    #[serde(default)]
161    pub threshold: Option<f64>,
162    /// Classifier calibration: what a "yes" (issue) looks like. Maps to the
163    /// noul question's `criteria.true`.
164    #[serde(default)]
165    pub if_true: Option<String>,
166    /// Classifier calibration: what a "no" (not an issue) looks like. Maps to
167    /// the noul question's `criteria.false`.
168    #[serde(default)]
169    pub if_false: Option<String>,
170}
171
172fn default_enabled() -> bool {
173    true
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Rule {
178    pub id: RuleId,
179    pub language: Language,
180    pub severity: Severity,
181    pub message: String,
182    #[serde(default)]
183    pub note: Option<String>,
184    #[serde(default)]
185    pub category: Option<Category>,
186    #[serde(default)]
187    pub fix: Option<String>,
188    /// ast-grep rewrite (fix) template applied to this rule's match by
189    /// `scan --fix`. Distinct from `fix`, which is a free-text human message:
190    /// `rewrite` carries the replacement pattern (it may reference the rule's
191    /// metavariables). The AST detection pass never reads this field.
192    #[serde(default)]
193    pub rewrite: Option<String>,
194    /// Opt-in marker that this rule's `rewrite` is safe to apply automatically
195    /// (idempotent, no semantic change). `scan --fix` ignores the `rewrite` of
196    /// any rule without it. Defaults to `false`. On a rule from `custom_dirs`
197    /// or `[[rules.sources]]` it is not enough: the rule id must also be listed
198    /// in the user config's `fix.allow_external`.
199    #[serde(default)]
200    pub autofix_safe: bool,
201    /// Matcher used only by `scan --fix` to locate nodes to rewrite, when it must
202    /// be narrower than the detection `rule` (detect broadly, fix narrowly). A
203    /// rule can flag every variant of a pattern while auto-rewriting only the
204    /// subset whose `rewrite` is provably safe. `null` (the default) means
205    /// `--fix` reuses `rule`. The detection pass never reads this field.
206    /// `--fix` only rewrites an `autofix_rule` match whose range lies inside a
207    /// match of the detection `rule` (with its `constraints`), so it can narrow
208    /// but never widen what detection reports.
209    #[serde(default, skip_serializing_if = "Value::is_null")]
210    pub autofix_rule: Value,
211    #[serde(default)]
212    pub rule: Value,
213    /// ast-grep metavariable constraints, a sibling of `rule`. Passed through
214    /// verbatim so a rule can restrict a captured metavariable (e.g. bind the
215    /// receiver of `$R.clone()` to an already-owned value). `null` when unset.
216    #[serde(default, skip_serializing_if = "Value::is_null")]
217    pub constraints: Value,
218    /// File-level metric evaluated instead of `rule`. Mutually exclusive with it.
219    #[serde(default)]
220    pub metric: Option<Metric>,
221    /// The value `metric` must exceed for the rule to fire. Required with `metric`.
222    #[serde(default)]
223    pub threshold: Option<f64>,
224    /// Minimum file length in lines before a metric rule is evaluated. Below it
225    /// the file is skipped, so ratios are not distorted by tiny files (a small
226    /// module full of required doc comments). `None` means no floor.
227    #[serde(default)]
228    pub min_lines: Option<usize>,
229    /// Project-wide analysis evaluated instead of `rule` or `metric`, and
230    /// mutually exclusive with both. The kind selects builtin Rust logic; the
231    /// YAML only carries the rule's identity, severity and user-facing text.
232    #[serde(default)]
233    pub cross_file: Option<CrossFileKind>,
234    /// Per-file import resolution evaluated instead of `rule`, `metric` or
235    /// `cross_file`, and mutually exclusive with all three. The kind selects
236    /// builtin resolution logic; the YAML only carries the rule's identity,
237    /// severity and user-facing text. `$import` in `message` is replaced with
238    /// the unresolved specifier.
239    #[serde(default)]
240    pub resolution: Option<ResolutionKind>,
241    /// Directory a custom rule was loaded from, used to resolve test fixture
242    /// paths. `None` for builtin rules, which resolve against `BuiltinRules`.
243    #[serde(skip)]
244    pub source_dir: Option<PathBuf>,
245    /// Provenance of this rule (builtin, git source, or local path), stamped at
246    /// load time and reported by `slopguard list`. Never present in YAML.
247    #[serde(skip)]
248    pub origin: RuleOrigin,
249    #[serde(default)]
250    pub files: Option<Vec<String>>,
251    #[serde(default)]
252    pub ignores: Option<Vec<String>>,
253    /// Drop findings located inside `#[cfg(test)]` blocks of scanned Rust files.
254    /// Complements `ignores`, which only filters whole files by path.
255    #[serde(default = "default_enabled")]
256    pub enabled: bool,
257    #[serde(default)]
258    pub skip_test_code: bool,
259    #[serde(default)]
260    pub tests: Option<RuleTests>,
261    /// When set, matches of `rule` are candidates confirmed by an LLM before
262    /// being reported. See [`AiCheck`]. The AST scanner ignores this field.
263    #[serde(default)]
264    pub ai_check: Option<AiCheck>,
265}
266
267impl Rule {
268    /// The rule's category, resolving an unset category to the default
269    /// (`Correctness`), the way the scanner treats it.
270    pub fn effective_category(&self) -> Category {
271        self.category.clone().unwrap_or_default()
272    }
273
274    /// Whether this rule measures a file-level metric instead of matching AST.
275    pub fn is_metric(&self) -> bool {
276        self.metric.is_some()
277    }
278
279    /// The metric and threshold of a file-level rule, `None` for AST rules.
280    pub fn metric_spec(&self) -> Option<(Metric, f64)> {
281        Some((self.metric?, self.threshold?))
282    }
283
284    /// Whether this rule is evaluated by the project-wide pass instead of per file.
285    pub fn is_cross_file(&self) -> bool {
286        self.cross_file.is_some()
287    }
288
289    /// Whether this rule is evaluated by the per-file import resolution pass.
290    pub fn is_resolution(&self) -> bool {
291        self.resolution.is_some()
292    }
293
294    /// Whether `scan --fix` may rewrite this rule's matches: it carries a
295    /// `rewrite` template and is explicitly marked `autofix_safe`. An external
296    /// (non-builtin) rule additionally needs its id in `fix.allow_external`,
297    /// which `scan --fix` checks on top of this.
298    pub fn is_autofixable(&self) -> bool {
299        self.autofix_safe && self.rewrite.is_some()
300    }
301
302    /// The matcher `scan --fix` uses to find nodes to rewrite: the dedicated
303    /// `autofix_rule` when set, otherwise the detection `rule`.
304    pub fn fix_matcher(&self) -> &Value {
305        if self.autofix_rule.is_null() {
306            &self.rule
307        } else {
308            &self.autofix_rule
309        }
310    }
311
312    /// The cross-file analysis this rule requests, `None` for AST and metric rules.
313    // Read-only accessor for a private field.
314    // slopguard-disable-next-line no-trivial-function
315    pub fn cross_file_kind(&self) -> Option<CrossFileKind> {
316        self.cross_file
317    }
318
319    /// The resolution analysis this rule requests, `None` for all other rules.
320    // Read-only accessor for a private field.
321    // slopguard-disable-next-line no-trivial-function
322    pub fn resolution_kind(&self) -> Option<ResolutionKind> {
323        self.resolution
324    }
325}
326
327#[derive(Debug, Error)]
328pub enum RuleError {
329    #[error("failed to parse rule YAML: {0}")]
330    Parse(#[from] serde_yaml::Error),
331
332    #[error("rule '{id}': rule field must not be empty")]
333    EmptyRule { id: RuleId },
334
335    #[error("rule '{id}': 'metric' and 'rule' are mutually exclusive")]
336    MetricAndRule { id: RuleId },
337
338    #[error("rule '{id}': 'cross_file' and 'rule' are mutually exclusive")]
339    CrossFileAndRule { id: RuleId },
340
341    #[error("rule '{id}': 'cross_file' and 'metric' are mutually exclusive")]
342    CrossFileAndMetric { id: RuleId },
343
344    #[error("rule '{id}': 'resolution' and 'rule' are mutually exclusive")]
345    ResolutionAndRule { id: RuleId },
346
347    #[error("rule '{id}': 'resolution' and 'metric' are mutually exclusive")]
348    ResolutionAndMetric { id: RuleId },
349
350    #[error("rule '{id}': 'resolution' and 'cross_file' are mutually exclusive")]
351    ResolutionAndCrossFile { id: RuleId },
352
353    #[error("rule '{id}': a metric rule requires a 'threshold'")]
354    MissingThreshold { id: RuleId },
355
356    #[error("rule '{id}': test fixture files are only supported for metric rules")]
357    FixturesOnAstRule { id: RuleId },
358
359    #[error("rule '{id}': fixture path '{path}' must be relative and must not contain '..'")]
360    InvalidFixturePath { id: RuleId, path: String },
361
362    #[error("rule '{id}': fixture '{path}' not found")]
363    FixtureNotFound { id: RuleId, path: String },
364
365    #[error("duplicate rule id: '{id}'")]
366    DuplicateId { id: RuleId },
367
368    #[error("rule '{id}' from source '{origin}' reuses a builtin rule id; rename it (external sources may not shadow builtin rules)")]
369    SourceReusesBuiltinId { id: RuleId, origin: String },
370
371    #[error("failed to read rule file '{path}': {source}")]
372    Io {
373        path: String,
374        #[source]
375        source: IoError,
376    },
377
378    #[error("rule file '{path}' is not valid UTF-8: {source}")]
379    Utf8 {
380        path: String,
381        #[source]
382        source: Utf8Error,
383    },
384}
385
386impl RuleError {
387    fn io(path: &Path, source: IoError) -> Self {
388        Self::Io {
389            path: path.display().to_string(),
390            source,
391        }
392    }
393}
394
395/// Parse a single YAML string into a Rule, validating required fields.
396///
397/// A rule carries exactly one matcher: an ast-grep `rule`, a file-level
398/// `metric` plus its `threshold`, or a project-wide `cross_file` kind.
399pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
400    let rule: Rule = serde_yaml::from_str(yaml)?;
401    let has_rule = !rule.rule.is_null();
402    let has_metric = rule.metric.is_some();
403    let has_cross = rule.cross_file.is_some();
404    let has_resolution = rule.resolution.is_some();
405    if has_resolution {
406        if has_rule {
407            return Err(RuleError::ResolutionAndRule { id: rule.id });
408        }
409        if has_metric {
410            return Err(RuleError::ResolutionAndMetric { id: rule.id });
411        }
412        if has_cross {
413            return Err(RuleError::ResolutionAndCrossFile { id: rule.id });
414        }
415    }
416    match (has_cross, has_metric, has_rule) {
417        (true, _, true) => return Err(RuleError::CrossFileAndRule { id: rule.id }),
418        (true, true, _) => return Err(RuleError::CrossFileAndMetric { id: rule.id }),
419        (false, true, true) => return Err(RuleError::MetricAndRule { id: rule.id }),
420        (false, true, false) if rule.threshold.is_none() => {
421            return Err(RuleError::MissingThreshold { id: rule.id })
422        }
423        (false, false, false) if !has_resolution => {
424            return Err(RuleError::EmptyRule { id: rule.id })
425        }
426        _ => {}
427    }
428    if !rule.is_metric() {
429        if let Some(tests) = &rule.tests {
430            if !tests.should_match_files.is_empty() || !tests.should_not_match_files.is_empty() {
431                return Err(RuleError::FixturesOnAstRule { id: rule.id });
432            }
433        }
434    }
435    Ok(rule)
436}
437
438/// Read a whole-file test fixture referenced by `tests.should_match_files` or
439/// `tests.should_not_match_files`. Builtin rules resolve against the embedded
440/// ruleset; custom rules resolve against the directory they were loaded from.
441pub fn read_fixture(rule: &Rule, relative: &str) -> Result<String, RuleError> {
442    let path = Path::new(relative);
443    let traverses = path.components().any(|c| matches!(c, Component::ParentDir));
444    if path.is_absolute() || traverses {
445        return Err(RuleError::InvalidFixturePath {
446            id: rule.id.clone(),
447            path: relative.to_string(),
448        });
449    }
450
451    match &rule.source_dir {
452        Some(dir) => {
453            let full = dir.join(path);
454            read_to_string(&full).map_err(|e| {
455                if e.kind() == std::io::ErrorKind::NotFound {
456                    RuleError::FixtureNotFound {
457                        id: rule.id.clone(),
458                        path: relative.to_string(),
459                    }
460                } else {
461                    RuleError::io(&full, e)
462                }
463            })
464        }
465        None => {
466            let file = BuiltinRules::get(relative).ok_or_else(|| RuleError::FixtureNotFound {
467                id: rule.id.clone(),
468                path: relative.to_string(),
469            })?;
470            from_utf8(&file.data)
471                .map(str::to_string)
472                .map_err(|e| RuleError::Utf8 {
473                    path: relative.to_string(),
474                    source: e,
475                })
476        }
477    }
478}
479
480/// Parse a rule and fill in its category from the ruleset-relative path when
481/// the YAML omits it.
482fn parse_rule_at(yaml: &str, relative_path: &Path) -> Result<Rule, RuleError> {
483    let mut rule = parse_rule(yaml)?;
484    if rule.category.is_none() {
485        rule.category = Category::from_rule_path(relative_path);
486    }
487    Ok(rule)
488}
489
490fn is_rule_file(path: &Path) -> bool {
491    matches!(
492        path.extension().and_then(|e| e.to_str()),
493        Some("yml" | "yaml")
494    )
495}
496
497/// Load all builtin rules embedded via rust-embed.
498pub fn load_builtin_rules() -> Result<Vec<Rule>, RuleError> {
499    BuiltinRules::iter()
500        .filter(|path| is_rule_file(Path::new(path.as_ref())))
501        .filter_map(|path| BuiltinRules::get(&path).map(|file| (path, file)))
502        .map(|(path, file)| {
503            let yaml = from_utf8(&file.data).map_err(|e| RuleError::Utf8 {
504                path: path.to_string(),
505                source: e,
506            })?;
507            parse_rule_at(yaml, Path::new(path.as_ref()))
508        })
509        .collect()
510}
511
512/// Load every rule YAML found under `dir`, stamping each with `dir` as its
513/// fixture root and `origin` as its provenance. Subdirectories are searched
514/// recursively.
515fn load_rules_in_dir(dir: &Path, origin: &RuleOrigin) -> Result<Vec<Rule>, RuleError> {
516    let mut rules = Vec::new();
517    // Rule directories are plain file trees: gitignore and hidden-file
518    // filtering would silently drop rules.
519    for entry in WalkBuilder::new(dir).standard_filters(false).build() {
520        let entry = entry.map_err(|e| RuleError::io(dir, IoError::other(e)))?;
521        let path = entry.path();
522        let is_file = entry.file_type().is_some_and(|t| t.is_file());
523        if !is_file || !is_rule_file(path) {
524            continue;
525        }
526        let yaml = read_to_string(path).map_err(|e| RuleError::io(path, e))?;
527        let relative = path.strip_prefix(dir).unwrap_or(path);
528        let mut rule = parse_rule_at(&yaml, relative)?;
529        rule.source_dir = Some(dir.to_path_buf());
530        rule.origin = origin.clone();
531        rules.push(rule);
532    }
533    Ok(rules)
534}
535
536/// Load rules from custom directories on the filesystem. Missing directories
537/// are skipped; subdirectories are searched recursively.
538pub fn load_custom_rules(dirs: &[PathBuf]) -> Result<Vec<Rule>, RuleError> {
539    let mut rules = Vec::new();
540    for dir in dirs.iter().filter(|dir| dir.is_dir()) {
541        let origin = RuleOrigin::Local { path: dir.clone() };
542        rules.extend(load_rules_in_dir(dir, &origin)?);
543    }
544    Ok(rules)
545}
546
547/// Load rules from resolved external sources (git clones and local paths),
548/// stamping each rule with the source's provenance. A source directory that is
549/// missing (e.g. a git `path` sub-directory that does not exist) is skipped.
550pub fn load_source_rules(sources: &[ResolvedSource]) -> Result<Vec<Rule>, RuleError> {
551    let mut rules = Vec::new();
552    for source in sources.iter().filter(|s| s.dir.is_dir()) {
553        rules.extend(load_rules_in_dir(&source.dir, &source.origin)?);
554    }
555    Ok(rules)
556}
557
558/// Reject any external rule whose (id, language) collides with a builtin rule.
559/// External sources extend the ruleset; they must never shadow a builtin id.
560fn reject_builtin_id_reuse(external: &[Rule], builtin: &[Rule]) -> Result<(), RuleError> {
561    let builtin_ids: HashSet<(&RuleId, &Language)> =
562        builtin.iter().map(|r| (&r.id, &r.language)).collect();
563    for rule in external {
564        if builtin_ids.contains(&(&rule.id, &rule.language)) {
565            return Err(RuleError::SourceReusesBuiltinId {
566                id: rule.id.clone(),
567                origin: rule.origin.label(),
568            });
569        }
570    }
571    Ok(())
572}
573
574/// Whether a rule (builtin or external) takes part in a scan under `config`.
575///
576/// A rule listed in `rules.enable` is always active. Otherwise it must be
577/// enabled by default, belong to an active ruleset, and not be listed in
578/// `rules.disable`.
579pub fn is_rule_active(rule: &Rule, config: &crate::config::Config) -> bool {
580    let listed = |ids: &[String]| ids.iter().any(|id| id == rule.id.as_str());
581    if listed(&config.rules.enable) {
582        return true;
583    }
584    let ruleset_on = match rule.effective_category() {
585        Category::Slop => config.rulesets.slop,
586        Category::Security => config.rulesets.security,
587        Category::Correctness => config.rulesets.correctness,
588    };
589    ruleset_on && rule.enabled && !listed(&config.rules.disable)
590}
591
592/// Load the non-builtin rules (custom directories + external sources) and
593/// reject any of them that reuses a builtin id: neither a custom directory nor
594/// a source may shadow a builtin rule. Custom rules come first, then source
595/// rules, matching the order both public loaders expose.
596fn load_external_rules(
597    config: &crate::config::Config,
598    sources: &[ResolvedSource],
599    builtin: &[Rule],
600) -> Result<(Vec<Rule>, Vec<Rule>), RuleError> {
601    let custom = load_custom_rules(&config.rules.custom_dirs)?;
602    let from_sources = load_source_rules(sources)?;
603    reject_builtin_id_reuse(&custom, builtin)?;
604    reject_builtin_id_reuse(&from_sources, builtin)?;
605    Ok((custom, from_sources))
606}
607
608/// Load all effective rules based on config: builtin, custom rules from
609/// configured directories, and rules from resolved external sources, all
610/// filtered by [`is_rule_active`] (rulesets, `enabled: false`, and the
611/// enable/disable lists). An external rule reusing a builtin id is rejected
612/// before any filtering.
613pub fn load_effective_rules(
614    config: &crate::config::Config,
615    sources: &[ResolvedSource],
616) -> Result<Vec<Rule>, RuleError> {
617    let builtin = load_builtin_rules()?;
618    let (custom, from_sources) = load_external_rules(config, sources, &builtin)?;
619
620    // Rules from `custom_dirs` are an explicit opt-in and are not gated by
621    // rulesets or `enabled`; builtin and source rules are.
622    let mut rules: Vec<Rule> = builtin
623        .into_iter()
624        .chain(from_sources)
625        .filter(|rule| is_rule_active(rule, config))
626        .collect();
627    rules.extend(custom);
628    validate_unique_ids(&rules)?;
629    Ok(rules)
630}
631
632/// Load all rules (builtin + custom + external sources) without filtering by
633/// activation status. Used by `explain` to look up any rule regardless of
634/// config, so an opt-in or disabled rule can still be explained.
635pub fn load_all_rules(
636    config: &crate::config::Config,
637    sources: &[ResolvedSource],
638) -> Result<Vec<Rule>, RuleError> {
639    let builtin = load_builtin_rules()?;
640    let (custom, from_sources) = load_external_rules(config, sources, &builtin)?;
641
642    let mut rules = builtin;
643    rules.extend(custom);
644    rules.extend(from_sources);
645    Ok(rules)
646}
647
648/// Validate that all rule (id, language) pairs are unique. The same rule id
649/// may appear for different languages (e.g. `no-todo-fixme` for both Rust and
650/// TypeScript), so a single `rules.disable` entry covers both variants.
651pub fn validate_unique_ids(rules: &[Rule]) -> Result<(), RuleError> {
652    let mut seen = HashSet::new();
653    for rule in rules {
654        if !seen.insert((&rule.id, &rule.language)) {
655            return Err(RuleError::DuplicateId {
656                id: rule.id.clone(),
657            });
658        }
659    }
660    Ok(())
661}
662
663#[cfg(test)]
664mod load_tests;
665#[cfg(test)]
666mod parse_tests;