Skip to main content

slopguard_core/
rule.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;
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct RuleId(pub String);
21
22impl fmt::Display for RuleId {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        f.write_str(&self.0)
25    }
26}
27
28impl RuleId {
29    pub fn as_str(&self) -> &str {
30        &self.0
31    }
32}
33
34impl From<String> for RuleId {
35    fn from(s: String) -> Self {
36        Self(s)
37    }
38}
39
40impl From<&str> for RuleId {
41    fn from(s: &str) -> Self {
42        Self(s.to_string())
43    }
44}
45
46#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48#[strum(serialize_all = "lowercase")]
49pub enum Severity {
50    Error,
51    Warning,
52}
53
54#[derive(Debug, Display, EnumIter, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56#[strum(serialize_all = "lowercase")]
57pub enum Language {
58    Rust,
59    TypeScript,
60}
61
62impl Language {
63    /// The ast-grep languages a rule written for this language is compiled
64    /// against. TypeScript rules also cover TSX files.
65    pub fn ast_grep_langs(&self) -> &'static [SupportLang] {
66        match self {
67            Language::Rust => &[SupportLang::Rust],
68            Language::TypeScript => &[SupportLang::TypeScript, SupportLang::Tsx],
69        }
70    }
71}
72
73#[derive(Debug, Display, EnumString, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75#[strum(serialize_all = "lowercase")]
76pub enum Category {
77    Slop,
78    Security,
79    #[default]
80    Correctness,
81}
82
83impl Category {
84    /// Derive the category from the first directory of a rule path relative to
85    /// its ruleset root (e.g. `security/no-debug.yml`). Returns `None` when the
86    /// path has no directory or the directory is not a known category.
87    fn from_rule_path(path: &Path) -> Option<Self> {
88        match path.components().next()? {
89            Component::Normal(dir) => dir.to_str()?.parse().ok(),
90            _ => None,
91        }
92    }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct RuleTests {
97    #[serde(default)]
98    pub should_match: Vec<String>,
99    #[serde(default)]
100    pub should_not_match: Vec<String>,
101    /// Whole-file fixtures for metric rules, resolved against the embedded
102    /// ruleset root (builtin rules) or the custom rule directory the rule was
103    /// loaded from. A metric cannot be measured on a snippet.
104    #[serde(default)]
105    pub should_match_files: Vec<String>,
106    #[serde(default)]
107    pub should_not_match_files: Vec<String>,
108}
109
110/// Optional AI confirmation step for a rule. When present, the rule's `rule`
111/// AST pattern acts as a cheap pre-filter: matches become candidates that an
112/// LLM must confirm before they are reported. The AST layer never executes
113/// this field; the AI pipeline in `slopguard-ai` reads it.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct AiCheck {
116    /// Prompt template sent to the model. Supports the variables `{{code}}`,
117    /// `{{filename}}`, and `{{rule_context}}`.
118    pub prompt: String,
119    /// Model override for this rule. Falls back to `[ai].model` in config.
120    #[serde(default)]
121    pub model: Option<String>,
122}
123
124fn default_enabled() -> bool {
125    true
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct Rule {
130    pub id: RuleId,
131    pub language: Language,
132    pub severity: Severity,
133    pub message: String,
134    #[serde(default)]
135    pub note: Option<String>,
136    #[serde(default)]
137    pub category: Option<Category>,
138    #[serde(default)]
139    pub fix: Option<String>,
140    #[serde(default)]
141    pub rule: Value,
142    /// File-level metric evaluated instead of `rule`. Mutually exclusive with it.
143    #[serde(default)]
144    pub metric: Option<Metric>,
145    /// The value `metric` must exceed for the rule to fire. Required with `metric`.
146    #[serde(default)]
147    pub threshold: Option<f64>,
148    /// Project-wide analysis evaluated instead of `rule` or `metric`, and
149    /// mutually exclusive with both. The kind selects builtin Rust logic; the
150    /// YAML only carries the rule's identity, severity and user-facing text.
151    #[serde(default)]
152    pub cross_file: Option<CrossFileKind>,
153    /// Directory a custom rule was loaded from, used to resolve test fixture
154    /// paths. `None` for builtin rules, which resolve against `BuiltinRules`.
155    #[serde(skip)]
156    pub source_dir: Option<PathBuf>,
157    #[serde(default)]
158    pub files: Option<Vec<String>>,
159    #[serde(default)]
160    pub ignores: Option<Vec<String>>,
161    /// Drop findings located inside `#[cfg(test)]` blocks of scanned Rust files.
162    /// Complements `ignores`, which only filters whole files by path.
163    #[serde(default = "default_enabled")]
164    pub enabled: bool,
165    #[serde(default)]
166    pub skip_test_code: bool,
167    #[serde(default)]
168    pub tests: Option<RuleTests>,
169    /// When set, matches of `rule` are candidates confirmed by an LLM before
170    /// being reported. See [`AiCheck`]. The AST scanner ignores this field.
171    #[serde(default)]
172    pub ai_check: Option<AiCheck>,
173}
174
175impl Rule {
176    /// The rule's category, resolving an unset category to the default
177    /// (`Correctness`), the way the scanner treats it.
178    pub fn effective_category(&self) -> Category {
179        self.category.clone().unwrap_or_default()
180    }
181
182    /// Whether this rule measures a file-level metric instead of matching AST.
183    pub fn is_metric(&self) -> bool {
184        self.metric.is_some()
185    }
186
187    /// The metric and threshold of a file-level rule, `None` for AST rules.
188    pub fn metric_spec(&self) -> Option<(Metric, f64)> {
189        Some((self.metric?, self.threshold?))
190    }
191
192    /// Whether this rule is evaluated by the project-wide pass instead of per file.
193    pub fn is_cross_file(&self) -> bool {
194        self.cross_file.is_some()
195    }
196
197    /// The cross-file analysis this rule requests, `None` for AST and metric rules.
198    pub fn cross_file_kind(&self) -> Option<CrossFileKind> {
199        self.cross_file
200    }
201}
202
203#[derive(Debug, Error)]
204pub enum RuleError {
205    #[error("failed to parse rule YAML: {0}")]
206    Parse(#[from] serde_yaml::Error),
207
208    #[error("rule '{id}': rule field must not be empty")]
209    EmptyRule { id: RuleId },
210
211    #[error("rule '{id}': 'metric' and 'rule' are mutually exclusive")]
212    MetricAndRule { id: RuleId },
213
214    #[error("rule '{id}': 'cross_file' and 'rule' are mutually exclusive")]
215    CrossFileAndRule { id: RuleId },
216
217    #[error("rule '{id}': 'cross_file' and 'metric' are mutually exclusive")]
218    CrossFileAndMetric { id: RuleId },
219
220    #[error("rule '{id}': a metric rule requires a 'threshold'")]
221    MissingThreshold { id: RuleId },
222
223    #[error("rule '{id}': test fixture files are only supported for metric rules")]
224    FixturesOnAstRule { id: RuleId },
225
226    #[error("rule '{id}': fixture path '{path}' must be relative and must not contain '..'")]
227    InvalidFixturePath { id: RuleId, path: String },
228
229    #[error("rule '{id}': fixture '{path}' not found")]
230    FixtureNotFound { id: RuleId, path: String },
231
232    #[error("duplicate rule id: '{id}'")]
233    DuplicateId { id: RuleId },
234
235    #[error("failed to read rule file '{path}': {source}")]
236    Io {
237        path: String,
238        #[source]
239        source: IoError,
240    },
241
242    #[error("rule file '{path}' is not valid UTF-8: {source}")]
243    Utf8 {
244        path: String,
245        #[source]
246        source: Utf8Error,
247    },
248}
249
250impl RuleError {
251    fn io(path: &Path, source: IoError) -> Self {
252        Self::Io {
253            path: path.display().to_string(),
254            source,
255        }
256    }
257}
258
259/// Parse a single YAML string into a Rule, validating required fields.
260///
261/// A rule carries exactly one matcher: an ast-grep `rule`, a file-level
262/// `metric` plus its `threshold`, or a project-wide `cross_file` kind.
263pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
264    let rule: Rule = serde_yaml::from_str(yaml)?;
265    let has_rule = !rule.rule.is_null();
266    let has_metric = rule.metric.is_some();
267    let has_cross = rule.cross_file.is_some();
268    match (has_cross, has_metric, has_rule) {
269        (true, _, true) => return Err(RuleError::CrossFileAndRule { id: rule.id }),
270        (true, true, _) => return Err(RuleError::CrossFileAndMetric { id: rule.id }),
271        (false, true, true) => return Err(RuleError::MetricAndRule { id: rule.id }),
272        (false, true, false) if rule.threshold.is_none() => {
273            return Err(RuleError::MissingThreshold { id: rule.id })
274        }
275        (false, false, false) => return Err(RuleError::EmptyRule { id: rule.id }),
276        _ => {}
277    }
278    if !rule.is_metric() {
279        if let Some(tests) = &rule.tests {
280            if !tests.should_match_files.is_empty() || !tests.should_not_match_files.is_empty() {
281                return Err(RuleError::FixturesOnAstRule { id: rule.id });
282            }
283        }
284    }
285    Ok(rule)
286}
287
288/// Read a whole-file test fixture referenced by `tests.should_match_files` or
289/// `tests.should_not_match_files`. Builtin rules resolve against the embedded
290/// ruleset; custom rules resolve against the directory they were loaded from.
291pub fn read_fixture(rule: &Rule, relative: &str) -> Result<String, RuleError> {
292    let path = Path::new(relative);
293    let traverses = path.components().any(|c| matches!(c, Component::ParentDir));
294    if path.is_absolute() || traverses {
295        return Err(RuleError::InvalidFixturePath {
296            id: rule.id.clone(),
297            path: relative.to_string(),
298        });
299    }
300
301    match &rule.source_dir {
302        Some(dir) => {
303            let full = dir.join(path);
304            read_to_string(&full).map_err(|e| {
305                if e.kind() == std::io::ErrorKind::NotFound {
306                    RuleError::FixtureNotFound {
307                        id: rule.id.clone(),
308                        path: relative.to_string(),
309                    }
310                } else {
311                    RuleError::io(&full, e)
312                }
313            })
314        }
315        None => {
316            let file = BuiltinRules::get(relative).ok_or_else(|| RuleError::FixtureNotFound {
317                id: rule.id.clone(),
318                path: relative.to_string(),
319            })?;
320            from_utf8(&file.data)
321                .map(str::to_string)
322                .map_err(|e| RuleError::Utf8 {
323                    path: relative.to_string(),
324                    source: e,
325                })
326        }
327    }
328}
329
330/// Parse a rule and fill in its category from the ruleset-relative path when
331/// the YAML omits it.
332fn parse_rule_at(yaml: &str, relative_path: &Path) -> Result<Rule, RuleError> {
333    let mut rule = parse_rule(yaml)?;
334    if rule.category.is_none() {
335        rule.category = Category::from_rule_path(relative_path);
336    }
337    Ok(rule)
338}
339
340fn is_rule_file(path: &Path) -> bool {
341    matches!(
342        path.extension().and_then(|e| e.to_str()),
343        Some("yml" | "yaml")
344    )
345}
346
347/// Load all builtin rules embedded via rust-embed.
348pub fn load_builtin_rules() -> Result<Vec<Rule>, RuleError> {
349    BuiltinRules::iter()
350        .filter(|path| is_rule_file(Path::new(path.as_ref())))
351        .filter_map(|path| BuiltinRules::get(&path).map(|file| (path, file)))
352        .map(|(path, file)| {
353            let yaml = from_utf8(&file.data).map_err(|e| RuleError::Utf8 {
354                path: path.to_string(),
355                source: e,
356            })?;
357            parse_rule_at(yaml, Path::new(path.as_ref()))
358        })
359        .collect()
360}
361
362/// Load rules from custom directories on the filesystem. Missing directories
363/// are skipped; subdirectories are searched recursively.
364pub fn load_custom_rules(dirs: &[PathBuf]) -> Result<Vec<Rule>, RuleError> {
365    let mut rules = Vec::new();
366    for dir in dirs.iter().filter(|dir| dir.is_dir()) {
367        // Custom rule directories are plain file trees: gitignore and
368        // hidden-file filtering would silently drop rules.
369        for entry in WalkBuilder::new(dir).standard_filters(false).build() {
370            let entry = entry.map_err(|e| RuleError::io(dir, IoError::other(e)))?;
371            let path = entry.path();
372            let is_file = entry.file_type().is_some_and(|t| t.is_file());
373            if !is_file || !is_rule_file(path) {
374                continue;
375            }
376            let yaml = read_to_string(path).map_err(|e| RuleError::io(path, e))?;
377            let relative = path.strip_prefix(dir).unwrap_or(path);
378            let mut rule = parse_rule_at(&yaml, relative)?;
379            rule.source_dir = Some(dir.clone());
380            rules.push(rule);
381        }
382    }
383    Ok(rules)
384}
385
386/// Whether a builtin rule takes part in a scan under `config`.
387///
388/// A rule listed in `rules.enable` is always active. Otherwise it must be
389/// enabled by default, belong to an active ruleset, and not be listed in
390/// `rules.disable`.
391pub fn is_rule_active(rule: &Rule, config: &crate::config::Config) -> bool {
392    let listed = |ids: &[String]| ids.iter().any(|id| id == rule.id.as_str());
393    if listed(&config.rules.enable) {
394        return true;
395    }
396    let ruleset_on = match rule.effective_category() {
397        Category::Slop => config.rulesets.slop,
398        Category::Security => config.rulesets.security,
399        Category::Correctness => config.rulesets.correctness,
400    };
401    ruleset_on && rule.enabled && !listed(&config.rules.disable)
402}
403
404/// Load all effective rules based on config: builtin (filtered by rulesets and
405/// the enable/disable lists) plus custom rules from configured directories.
406pub fn load_effective_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
407    let mut rules: Vec<Rule> = load_builtin_rules()?
408        .into_iter()
409        .filter(|rule| is_rule_active(rule, config))
410        .collect();
411
412    let custom = load_custom_rules(&config.rules.custom_dirs)?;
413    rules.extend(custom);
414    validate_unique_ids(&rules)?;
415    Ok(rules)
416}
417
418/// Load all rules (builtin + custom) without filtering by activation status.
419/// Used by `explain` to look up any rule regardless of config.
420pub fn load_all_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
421    let mut rules = load_builtin_rules()?;
422    let custom = load_custom_rules(&config.rules.custom_dirs)?;
423    rules.extend(custom);
424    Ok(rules)
425}
426
427/// Validate that all rule (id, language) pairs are unique. The same rule id
428/// may appear for different languages (e.g. `no-todo-fixme` for both Rust and
429/// TypeScript), so a single `rules.disable` entry covers both variants.
430pub fn validate_unique_ids(rules: &[Rule]) -> Result<(), RuleError> {
431    let mut seen = HashSet::new();
432    for rule in rules {
433        if !seen.insert((&rule.id, &rule.language)) {
434            return Err(RuleError::DuplicateId {
435                id: rule.id.clone(),
436            });
437        }
438    }
439    Ok(())
440}
441
442#[cfg(test)]
443mod tests {
444    use std::fs::{create_dir, write};
445
446    use tempfile::tempdir;
447
448    use super::*;
449
450    #[test]
451    fn parse_valid_rule_all_fields() {
452        let yaml = r#"
453id: test-rule
454language: rust
455severity: error
456message: "Test message"
457note: "Test note"
458category: security
459fix: "Use X instead"
460rule:
461  kind: call_expression
462  regex: '\.unwrap\(\)\s*$'
463files:
464  - "**/src/**/*.rs"
465ignores:
466  - "**/tests/**"
467skip_test_code: true
468tests:
469  should_match:
470    - "let x = foo().unwrap();"
471  should_not_match:
472    - "let x = foo()?;"
473"#;
474        let rule = parse_rule(yaml).unwrap();
475        assert!(rule.skip_test_code);
476        assert_eq!(rule.id, RuleId::from("test-rule"));
477        assert_eq!(rule.language, Language::Rust);
478        assert_eq!(rule.severity, Severity::Error);
479        assert_eq!(rule.message, "Test message");
480        assert_eq!(rule.note.as_deref(), Some("Test note"));
481        assert_eq!(rule.category, Some(Category::Security));
482        assert_eq!(rule.fix.as_deref(), Some("Use X instead"));
483        assert!(!rule.rule.is_null());
484        assert_eq!(rule.files.as_ref().unwrap().len(), 1);
485        assert_eq!(rule.ignores.as_ref().unwrap().len(), 1);
486        let tests = rule.tests.as_ref().unwrap();
487        assert_eq!(tests.should_match.len(), 1);
488        assert_eq!(tests.should_not_match.len(), 1);
489    }
490
491    #[test]
492    fn parse_rule_with_ai_check() {
493        let yaml = r#"
494id: ai-rule
495language: rust
496severity: warning
497category: slop
498message: "needs AI confirmation"
499rule:
500  kind: line_comment
501  regex: 'SAFETY'
502ai_check:
503  prompt: "Is this SAFETY comment meaningful?\n{{code}}"
504  model: "claude-haiku-4-5"
505"#;
506        let rule = parse_rule(yaml).unwrap();
507        let ai = rule.ai_check.as_ref().expect("ai_check should be present");
508        assert_eq!(ai.prompt, "Is this SAFETY comment meaningful?\n{{code}}");
509        assert_eq!(ai.model.as_deref(), Some("claude-haiku-4-5"));
510    }
511
512    #[test]
513    fn parse_rule_ai_check_without_model() {
514        let yaml = r#"
515id: ai-rule-no-model
516language: rust
517severity: warning
518category: slop
519message: "needs AI confirmation"
520rule:
521  kind: line_comment
522ai_check:
523  prompt: "check {{filename}}"
524"#;
525        let rule = parse_rule(yaml).unwrap();
526        let ai = rule.ai_check.as_ref().unwrap();
527        assert_eq!(ai.prompt, "check {{filename}}");
528        assert!(
529            ai.model.is_none(),
530            "model falls back to config when omitted"
531        );
532    }
533
534    #[test]
535    fn parse_minimal_rule() {
536        let yaml = r#"
537id: minimal-rule
538language: rust
539severity: warning
540message: "Minimal"
541rule:
542  pattern: $X.unwrap()
543"#;
544        let rule = parse_rule(yaml).unwrap();
545        assert_eq!(rule.id, RuleId::from("minimal-rule"));
546        assert_eq!(rule.severity, Severity::Warning);
547        assert!(rule.note.is_none());
548        assert!(rule.category.is_none());
549        assert!(rule.fix.is_none());
550        assert!(rule.files.is_none());
551        assert!(rule.ignores.is_none());
552        assert!(!rule.skip_test_code);
553        assert!(rule.tests.is_none());
554        assert!(rule.enabled);
555        assert!(rule.ai_check.is_none());
556    }
557
558    #[test]
559    fn rule_activation_precedence() {
560        let opt_in = parse_rule(
561            r#"
562id: opt-in
563language: rust
564severity: warning
565category: slop
566enabled: false
567message: "Opt-in"
568rule:
569  pattern: $X.unwrap()
570"#,
571        )
572        .unwrap();
573        let mut config = crate::config::Config::default();
574
575        assert!(!is_rule_active(&opt_in, &config), "enabled: false is off");
576
577        config.rules.enable.push("opt-in".to_string());
578        assert!(is_rule_active(&opt_in, &config), "enable list turns it on");
579
580        config.rules.disable.push("opt-in".to_string());
581        assert!(is_rule_active(&opt_in, &config), "enable wins over disable");
582
583        config.rulesets.slop = false;
584        assert!(is_rule_active(&opt_in, &config), "enable wins over ruleset");
585
586        config.rules.enable.clear();
587        config.rules.disable.clear();
588        config.rulesets.slop = true;
589        let mut default_on = opt_in.clone();
590        default_on.enabled = true;
591        assert!(is_rule_active(&default_on, &config));
592
593        config.rules.disable.push("opt-in".to_string());
594        assert!(!is_rule_active(&default_on, &config), "disable list");
595
596        config.rules.disable.clear();
597        config.rulesets.slop = false;
598        assert!(!is_rule_active(&default_on, &config), "ruleset off");
599    }
600
601    #[test]
602    fn reject_missing_id() {
603        let yaml = r#"
604language: rust
605severity: error
606message: "No id"
607rule:
608  pattern: $X
609"#;
610        let err = parse_rule(yaml).unwrap_err();
611        let msg = err.to_string();
612        assert!(
613            msg.contains("missing field"),
614            "expected missing field error, got: {msg}"
615        );
616    }
617
618    #[test]
619    fn reject_unsupported_language() {
620        let yaml = r#"
621id: bad-lang
622language: python
623severity: error
624message: "Bad lang"
625rule:
626  pattern: $X
627"#;
628        let err = parse_rule(yaml).unwrap_err();
629        assert!(matches!(err, RuleError::Parse(_)));
630        let msg = err.to_string();
631        assert!(
632            msg.contains("unknown variant"),
633            "expected unknown variant error, got: {msg}"
634        );
635    }
636
637    #[test]
638    fn reject_empty_rule() {
639        let yaml = r#"
640id: empty-rule
641language: rust
642severity: error
643message: "Empty rule"
644rule:
645"#;
646        let err = parse_rule(yaml).unwrap_err();
647        let msg = err.to_string();
648        assert!(
649            msg.contains("must not be empty"),
650            "expected empty rule error, got: {msg}"
651        );
652    }
653
654    #[test]
655    fn reject_malformed_yaml() {
656        let yaml = "this is: [not: valid: yaml: {{{";
657        let err = parse_rule(yaml).unwrap_err();
658        assert!(matches!(err, RuleError::Parse(_)));
659    }
660
661    #[test]
662    fn load_all_builtin_rules() {
663        let rules = load_builtin_rules().unwrap();
664        assert_eq!(
665            rules.len(),
666            97,
667            "expected 97 builtin rules, got {}",
668            rules.len()
669        );
670
671        let slop_count = rules
672            .iter()
673            .filter(|r| r.category == Some(Category::Slop))
674            .count();
675        let security_count = rules
676            .iter()
677            .filter(|r| r.category == Some(Category::Security))
678            .count();
679        let correctness_count = rules
680            .iter()
681            .filter(|r| r.category == Some(Category::Correctness))
682            .count();
683        assert_eq!(slop_count, 37, "expected 37 slop rules");
684        assert_eq!(security_count, 22, "expected 22 security rules");
685        assert_eq!(correctness_count, 38, "expected 38 correctness rules");
686
687        assert!(rules
688            .iter()
689            .any(|r| r.id == RuleId::from("no-unwrap-in-prod")));
690        assert!(rules
691            .iter()
692            .any(|r| r.id == RuleId::from("no-debug-on-secrets")));
693        assert!(rules.iter().any(|r| r.id == RuleId::from("no-slop-words")));
694        assert!(
695            rules
696                .iter()
697                .any(|r| r.id == RuleId::from("max-file-lines") && r.is_metric()),
698            "max-file-lines should be loaded as a metric rule"
699        );
700        assert!(
701            rules
702                .iter()
703                .any(|r| r.id == RuleId::from("no-single-impl-trait") && r.is_cross_file()),
704            "no-single-impl-trait should be loaded as a cross-file rule"
705        );
706
707        validate_unique_ids(&rules).unwrap();
708    }
709
710    #[test]
711    fn load_custom_rules_from_dir() {
712        let dir = tempdir().unwrap();
713        let rule_yaml = r#"
714id: custom-rule
715language: rust
716severity: warning
717message: "Custom rule"
718rule:
719  pattern: $X.clone()
720"#;
721        write(dir.path().join("custom.yml"), rule_yaml).unwrap();
722
723        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
724        assert_eq!(rules.len(), 1);
725        assert_eq!(rules[0].id, RuleId::from("custom-rule"));
726    }
727
728    #[test]
729    fn reject_duplicate_ids_same_language() {
730        let rule1 = parse_rule(
731            r#"
732id: same-id
733language: rust
734severity: error
735message: "First"
736rule:
737  pattern: $X
738"#,
739        )
740        .unwrap();
741        let rule2 = parse_rule(
742            r#"
743id: same-id
744language: rust
745severity: warning
746message: "Second"
747rule:
748  pattern: $Y
749"#,
750        )
751        .unwrap();
752        let err = validate_unique_ids(&[rule1, rule2]).unwrap_err();
753        let msg = err.to_string();
754        assert!(
755            msg.contains("duplicate rule id"),
756            "expected duplicate id error, got: {msg}"
757        );
758        assert!(
759            msg.contains("same-id"),
760            "expected same-id in error, got: {msg}"
761        );
762    }
763
764    #[test]
765    fn allow_same_id_different_language() {
766        let rust_rule = parse_rule(
767            r#"
768id: shared-rule
769language: rust
770severity: error
771message: "Rust variant"
772rule:
773  pattern: $X
774"#,
775        )
776        .unwrap();
777        let ts_rule = parse_rule(
778            r#"
779id: shared-rule
780language: typescript
781severity: error
782message: "TypeScript variant"
783rule:
784  pattern: $X
785"#,
786        )
787        .unwrap();
788        validate_unique_ids(&[rust_rule, ts_rule]).unwrap();
789    }
790
791    #[test]
792    fn category_from_rule_path() {
793        let category = |p: &str| Category::from_rule_path(Path::new(p));
794        assert_eq!(category("slop/no-slop-words.yml"), Some(Category::Slop));
795        assert_eq!(category("security/no-debug.yml"), Some(Category::Security));
796        assert_eq!(
797            category("correctness/no-unwrap.yml"),
798            Some(Category::Correctness)
799        );
800        assert_eq!(category("unknown/something.yml"), None);
801        assert_eq!(category("rule.yml"), None);
802    }
803
804    #[test]
805    fn all_builtin_rules_valid_messages() {
806        for path in BuiltinRules::iter() {
807            if !is_rule_file(Path::new(path.as_ref())) {
808                continue;
809            }
810            let file = BuiltinRules::get(&path).unwrap();
811            let yaml = from_utf8(&file.data).unwrap();
812            let rule: Rule = serde_yaml::from_str(yaml).unwrap();
813            assert!(
814                !rule.message.contains('\u{2014}'),
815                "rule '{}' message contains em dash: {}",
816                rule.id,
817                rule.message
818            );
819            assert!(
820                !rule.message.contains('\u{2013}'),
821                "rule '{}' message contains en dash: {}",
822                rule.id,
823                rule.message
824            );
825            assert!(
826                rule.message.is_ascii() || rule.message.chars().all(|c| c.is_ascii() || c == '\''),
827                "rule '{}' message contains non-ASCII characters: {}",
828                rule.id,
829                rule.message
830            );
831        }
832    }
833
834    #[test]
835    fn all_builtin_rules_have_tests() {
836        for path in BuiltinRules::iter() {
837            if !is_rule_file(Path::new(path.as_ref())) {
838                continue;
839            }
840            let file = BuiltinRules::get(&path).unwrap();
841            let yaml = from_utf8(&file.data).unwrap();
842            let rule: Rule = serde_yaml::from_str(yaml).unwrap();
843            // A cross-file rule needs a whole project to mean anything, so no
844            // snippet can exercise it.
845            if rule.cross_file.is_some() {
846                continue;
847            }
848            let tests = rule.tests.as_ref().unwrap_or_else(|| {
849                panic!("rule '{}' (at {path}) is missing `tests` block", rule.id)
850            });
851            if rule.is_metric() {
852                // Two near-identical whole-file fixtures add no coverage, so a
853                // metric rule only needs one case per direction.
854                assert!(
855                    tests.should_match.len() + tests.should_match_files.len() >= 1,
856                    "metric rule '{}' (at {path}) needs >= 1 should_match case",
857                    rule.id
858                );
859                assert!(
860                    tests.should_not_match.len() + tests.should_not_match_files.len() >= 1,
861                    "metric rule '{}' (at {path}) needs >= 1 should_not_match case",
862                    rule.id
863                );
864                continue;
865            }
866            assert!(
867                tests.should_match.len() >= 2,
868                "rule '{}' (at {path}) needs >= 2 should_match, got {}",
869                rule.id,
870                tests.should_match.len()
871            );
872            assert!(
873                tests.should_not_match.len() >= 2,
874                "rule '{}' (at {path}) needs >= 2 should_not_match, got {}",
875                rule.id,
876                tests.should_not_match.len()
877            );
878        }
879    }
880
881    #[test]
882    fn all_builtin_rules_have_explicit_category() {
883        for path in BuiltinRules::iter() {
884            if !is_rule_file(Path::new(path.as_ref())) {
885                continue;
886            }
887            let file = BuiltinRules::get(&path).unwrap();
888            let yaml = from_utf8(&file.data).unwrap();
889            let rule: Rule = serde_yaml::from_str(yaml).unwrap();
890            assert!(
891                rule.category.is_some(),
892                "rule '{}' (at {path}) is missing an explicit `category` field",
893                rule.id
894            );
895        }
896    }
897
898    #[test]
899    fn parse_metric_rule() {
900        let yaml = r#"
901id: max-lines
902language: rust
903severity: warning
904category: slop
905metric: file_lines
906threshold: 500
907message: "File exceeds 500 lines ($value lines)."
908"#;
909        let rule = parse_rule(yaml).unwrap();
910        assert_eq!(rule.metric, Some(Metric::FileLines));
911        assert_eq!(rule.threshold, Some(500.0));
912        assert!(rule.is_metric());
913        assert_eq!(rule.metric_spec(), Some((Metric::FileLines, 500.0)));
914        assert!(rule.rule.is_null());
915    }
916
917    #[test]
918    fn reject_metric_with_rule() {
919        let yaml = r#"
920id: both
921language: rust
922severity: warning
923category: slop
924metric: file_lines
925threshold: 500
926message: "m"
927rule:
928  pattern: $X
929"#;
930        let err = parse_rule(yaml).unwrap_err();
931        let msg = err.to_string();
932        assert!(
933            msg.contains("mutually exclusive"),
934            "expected mutually exclusive error, got: {msg}"
935        );
936    }
937
938    #[test]
939    fn reject_metric_without_threshold() {
940        let yaml = r#"
941id: no-threshold
942language: rust
943severity: warning
944category: slop
945metric: file_lines
946message: "m"
947"#;
948        let err = parse_rule(yaml).unwrap_err();
949        let msg = err.to_string();
950        assert!(
951            msg.contains("threshold"),
952            "expected threshold error, got: {msg}"
953        );
954    }
955
956    #[test]
957    fn reject_fixture_files_on_ast_rule() {
958        let yaml = r#"
959id: ast-with-fixture
960language: rust
961severity: warning
962category: slop
963message: "m"
964rule:
965  pattern: $X
966tests:
967  should_match_files:
968    - "fixtures/metrics/rust_large.rs"
969"#;
970        let err = parse_rule(yaml).unwrap_err();
971        let msg = err.to_string();
972        assert!(
973            msg.contains("only supported for metric rules"),
974            "expected fixture-on-ast-rule error, got: {msg}"
975        );
976    }
977
978    #[test]
979    fn parse_metric_rule_with_fixture_files() {
980        let yaml = r#"
981id: metric-with-fixtures
982language: rust
983severity: warning
984category: slop
985metric: import_count
986threshold: 40
987message: "m"
988tests:
989  should_match_files:
990    - "fixtures/metrics/rust_large.rs"
991  should_not_match_files:
992    - "fixtures/metrics/rust_small.rs"
993"#;
994        let rule = parse_rule(yaml).unwrap();
995        let tests = rule.tests.as_ref().unwrap();
996        assert_eq!(
997            tests.should_match_files,
998            vec!["fixtures/metrics/rust_large.rs".to_string()]
999        );
1000        assert_eq!(
1001            tests.should_not_match_files,
1002            vec!["fixtures/metrics/rust_small.rs".to_string()]
1003        );
1004    }
1005
1006    fn fixture_metric_rule() -> Rule {
1007        parse_rule(
1008            r#"
1009id: fixture-reader
1010language: rust
1011severity: warning
1012category: slop
1013metric: file_lines
1014threshold: 500
1015message: "m"
1016"#,
1017        )
1018        .unwrap()
1019    }
1020
1021    #[test]
1022    fn read_fixture_rejects_parent_dir() {
1023        let rule = fixture_metric_rule();
1024        let err = read_fixture(&rule, "../secrets.rs").unwrap_err();
1025        assert!(
1026            matches!(err, RuleError::InvalidFixturePath { .. }),
1027            "{err:?}"
1028        );
1029    }
1030
1031    #[test]
1032    fn read_fixture_rejects_absolute_path() {
1033        let rule = fixture_metric_rule();
1034        let err = read_fixture(&rule, "/etc/passwd").unwrap_err();
1035        assert!(
1036            matches!(err, RuleError::InvalidFixturePath { .. }),
1037            "{err:?}"
1038        );
1039    }
1040
1041    #[test]
1042    fn read_fixture_from_custom_dir() {
1043        let dir = tempdir().unwrap();
1044        let fixtures = dir.path().join("fixtures");
1045        create_dir(&fixtures).unwrap();
1046        write(
1047            fixtures.join("big.rs"),
1048            "fn a() {}
1049fn b() {}
1050",
1051        )
1052        .unwrap();
1053        write(
1054            dir.path().join("metric.yml"),
1055            r#"
1056id: custom-metric
1057language: rust
1058severity: warning
1059category: slop
1060metric: file_lines
1061threshold: 1
1062message: "m"
1063tests:
1064  should_match_files:
1065    - "fixtures/big.rs"
1066"#,
1067        )
1068        .unwrap();
1069
1070        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
1071        assert_eq!(rules.len(), 1);
1072        assert_eq!(rules[0].source_dir.as_deref(), Some(dir.path()));
1073        let content = read_fixture(&rules[0], "fixtures/big.rs").unwrap();
1074        assert_eq!(
1075            content,
1076            "fn a() {}
1077fn b() {}
1078"
1079        );
1080    }
1081
1082    #[test]
1083    fn read_fixture_missing_in_custom_dir() {
1084        let dir = tempdir().unwrap();
1085        write(
1086            dir.path().join("metric.yml"),
1087            r#"
1088id: custom-metric-missing
1089language: rust
1090severity: warning
1091category: slop
1092metric: file_lines
1093threshold: 1
1094message: "m"
1095"#,
1096        )
1097        .unwrap();
1098        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
1099        let err = read_fixture(&rules[0], "fixtures/nope.rs").unwrap_err();
1100        assert!(matches!(err, RuleError::FixtureNotFound { .. }), "{err:?}");
1101    }
1102
1103    #[test]
1104    fn read_builtin_fixture() {
1105        let rules = load_builtin_rules().unwrap();
1106        let rule = rules
1107            .iter()
1108            .find(|r| r.id == RuleId::from("max-file-lines"))
1109            .expect("max-file-lines should be a builtin rule");
1110        let content = read_fixture(rule, "fixtures/metrics/rust_large.rs").unwrap();
1111        assert!(
1112            content.lines().count() > 500,
1113            "the large fixture should exceed 500 lines, got {}",
1114            content.lines().count()
1115        );
1116    }
1117
1118    #[test]
1119    fn parse_cross_file_rule() {
1120        let yaml = r#"
1121id: no-single-impl-trait
1122language: rust
1123severity: warning
1124category: slop
1125cross_file: single_impl_trait
1126message: "Trait with a single implementation in the project."
1127"#;
1128        let rule = parse_rule(yaml).unwrap();
1129        assert_eq!(rule.cross_file, Some(CrossFileKind::SingleImplTrait));
1130        assert_eq!(rule.cross_file_kind(), Some(CrossFileKind::SingleImplTrait));
1131        assert!(rule.rule.is_null());
1132        assert!(rule.is_cross_file());
1133        assert!(!rule.is_metric());
1134    }
1135
1136    #[test]
1137    fn reject_cross_file_with_rule() {
1138        let yaml = r#"
1139id: cross-and-rule
1140language: rust
1141severity: warning
1142category: slop
1143cross_file: single_impl_trait
1144message: "m"
1145rule:
1146  pattern: $X
1147"#;
1148        let err = parse_rule(yaml).unwrap_err();
1149        let msg = err.to_string();
1150        assert!(
1151            msg.contains("mutually exclusive"),
1152            "expected mutually exclusive error, got: {msg}"
1153        );
1154    }
1155
1156    #[test]
1157    fn reject_cross_file_with_metric() {
1158        let yaml = r#"
1159id: cross-and-metric
1160language: rust
1161severity: warning
1162category: slop
1163cross_file: single_impl_trait
1164metric: file_lines
1165threshold: 500
1166message: "m"
1167"#;
1168        let err = parse_rule(yaml).unwrap_err();
1169        let msg = err.to_string();
1170        assert!(
1171            msg.contains("mutually exclusive"),
1172            "expected mutually exclusive error, got: {msg}"
1173        );
1174    }
1175
1176    #[test]
1177    fn reject_unknown_cross_file_kind() {
1178        let yaml = r#"
1179id: unknown-cross-file
1180language: rust
1181severity: warning
1182category: slop
1183cross_file: no_such_kind
1184message: "m"
1185"#;
1186        let err = parse_rule(yaml).unwrap_err();
1187        assert!(matches!(err, RuleError::Parse(_)));
1188        let msg = err.to_string();
1189        assert!(
1190            msg.contains("unknown variant"),
1191            "expected unknown variant error, got: {msg}"
1192        );
1193    }
1194
1195    #[test]
1196    fn custom_rules_category_from_subdir() {
1197        let dir = tempdir().unwrap();
1198        let security_dir = dir.path().join("security");
1199        create_dir(&security_dir).unwrap();
1200        let rule_yaml = r#"
1201id: custom-sec
1202language: rust
1203severity: error
1204message: "Custom security"
1205rule:
1206  pattern: $X
1207"#;
1208        write(security_dir.join("rule.yml"), rule_yaml).unwrap();
1209
1210        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
1211        assert_eq!(rules.len(), 1);
1212        assert_eq!(rules[0].category, Some(Category::Security));
1213    }
1214}