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
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub struct RuleId(pub String);
18
19impl fmt::Display for RuleId {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        f.write_str(&self.0)
22    }
23}
24
25impl RuleId {
26    pub fn as_str(&self) -> &str {
27        &self.0
28    }
29}
30
31impl From<String> for RuleId {
32    fn from(s: String) -> Self {
33        Self(s)
34    }
35}
36
37impl From<&str> for RuleId {
38    fn from(s: &str) -> Self {
39        Self(s.to_string())
40    }
41}
42
43#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45#[strum(serialize_all = "lowercase")]
46pub enum Severity {
47    Error,
48    Warning,
49}
50
51#[derive(Debug, Display, EnumIter, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53#[strum(serialize_all = "lowercase")]
54pub enum Language {
55    Rust,
56    TypeScript,
57}
58
59impl Language {
60    /// The ast-grep languages a rule written for this language is compiled
61    /// against. TypeScript rules also cover TSX files.
62    pub fn ast_grep_langs(&self) -> &'static [SupportLang] {
63        match self {
64            Language::Rust => &[SupportLang::Rust],
65            Language::TypeScript => &[SupportLang::TypeScript, SupportLang::Tsx],
66        }
67    }
68}
69
70#[derive(Debug, Display, EnumString, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72#[strum(serialize_all = "lowercase")]
73pub enum Category {
74    Slop,
75    Security,
76    #[default]
77    Correctness,
78}
79
80impl Category {
81    /// Derive the category from the first directory of a rule path relative to
82    /// its ruleset root (e.g. `security/no-debug.yml`). Returns `None` when the
83    /// path has no directory or the directory is not a known category.
84    fn from_rule_path(path: &Path) -> Option<Self> {
85        match path.components().next()? {
86            Component::Normal(dir) => dir.to_str()?.parse().ok(),
87            _ => None,
88        }
89    }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct RuleTests {
94    #[serde(default)]
95    pub should_match: Vec<String>,
96    #[serde(default)]
97    pub should_not_match: Vec<String>,
98}
99
100/// Optional AI confirmation step for a rule. When present, the rule's `rule`
101/// AST pattern acts as a cheap pre-filter: matches become candidates that an
102/// LLM must confirm before they are reported. The AST layer never executes
103/// this field; the AI pipeline in `slopguard-ai` reads it.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct AiCheck {
106    /// Prompt template sent to the model. Supports the variables `{{code}}`,
107    /// `{{filename}}`, and `{{rule_context}}`.
108    pub prompt: String,
109    /// Model override for this rule. Falls back to `[ai].model` in config.
110    #[serde(default)]
111    pub model: Option<String>,
112}
113
114fn default_enabled() -> bool {
115    true
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct Rule {
120    pub id: RuleId,
121    pub language: Language,
122    pub severity: Severity,
123    pub message: String,
124    #[serde(default)]
125    pub note: Option<String>,
126    #[serde(default)]
127    pub category: Option<Category>,
128    #[serde(default)]
129    pub fix: Option<String>,
130    pub rule: Value,
131    #[serde(default)]
132    pub files: Option<Vec<String>>,
133    #[serde(default)]
134    pub ignores: Option<Vec<String>>,
135    /// Drop findings located inside `#[cfg(test)]` blocks of scanned Rust files.
136    /// Complements `ignores`, which only filters whole files by path.
137    #[serde(default = "default_enabled")]
138    pub enabled: bool,
139    #[serde(default)]
140    pub skip_test_code: bool,
141    #[serde(default)]
142    pub tests: Option<RuleTests>,
143    /// When set, matches of `rule` are candidates confirmed by an LLM before
144    /// being reported. See [`AiCheck`]. The AST scanner ignores this field.
145    #[serde(default)]
146    pub ai_check: Option<AiCheck>,
147}
148
149impl Rule {
150    /// The rule's category, resolving an unset category to the default
151    /// (`Correctness`), the way the scanner treats it.
152    pub fn effective_category(&self) -> Category {
153        self.category.clone().unwrap_or_default()
154    }
155}
156
157#[derive(Debug, Error)]
158pub enum RuleError {
159    #[error("failed to parse rule YAML: {0}")]
160    Parse(#[from] serde_yaml::Error),
161
162    #[error("rule '{id}': rule field must not be empty")]
163    EmptyRule { id: RuleId },
164
165    #[error("duplicate rule id: '{id}'")]
166    DuplicateId { id: RuleId },
167
168    #[error("failed to read rule file '{path}': {source}")]
169    Io {
170        path: String,
171        #[source]
172        source: IoError,
173    },
174
175    #[error("rule file '{path}' is not valid UTF-8: {source}")]
176    Utf8 {
177        path: String,
178        #[source]
179        source: Utf8Error,
180    },
181}
182
183impl RuleError {
184    fn io(path: &Path, source: IoError) -> Self {
185        Self::Io {
186            path: path.display().to_string(),
187            source,
188        }
189    }
190}
191
192/// Parse a single YAML string into a Rule, validating required fields.
193pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
194    let rule: Rule = serde_yaml::from_str(yaml)?;
195    if rule.rule.is_null() {
196        return Err(RuleError::EmptyRule { id: rule.id });
197    }
198    Ok(rule)
199}
200
201/// Parse a rule and fill in its category from the ruleset-relative path when
202/// the YAML omits it.
203fn parse_rule_at(yaml: &str, relative_path: &Path) -> Result<Rule, RuleError> {
204    let mut rule = parse_rule(yaml)?;
205    if rule.category.is_none() {
206        rule.category = Category::from_rule_path(relative_path);
207    }
208    Ok(rule)
209}
210
211fn is_rule_file(path: &Path) -> bool {
212    matches!(
213        path.extension().and_then(|e| e.to_str()),
214        Some("yml" | "yaml")
215    )
216}
217
218/// Load all builtin rules embedded via rust-embed.
219pub fn load_builtin_rules() -> Result<Vec<Rule>, RuleError> {
220    BuiltinRules::iter()
221        .filter(|path| is_rule_file(Path::new(path.as_ref())))
222        .filter_map(|path| BuiltinRules::get(&path).map(|file| (path, file)))
223        .map(|(path, file)| {
224            let yaml = from_utf8(&file.data).map_err(|e| RuleError::Utf8 {
225                path: path.to_string(),
226                source: e,
227            })?;
228            parse_rule_at(yaml, Path::new(path.as_ref()))
229        })
230        .collect()
231}
232
233/// Load rules from custom directories on the filesystem. Missing directories
234/// are skipped; subdirectories are searched recursively.
235pub fn load_custom_rules(dirs: &[PathBuf]) -> Result<Vec<Rule>, RuleError> {
236    let mut rules = Vec::new();
237    for dir in dirs.iter().filter(|dir| dir.is_dir()) {
238        // Custom rule directories are plain file trees: gitignore and
239        // hidden-file filtering would silently drop rules.
240        for entry in WalkBuilder::new(dir).standard_filters(false).build() {
241            let entry = entry.map_err(|e| RuleError::io(dir, IoError::other(e)))?;
242            let path = entry.path();
243            let is_file = entry.file_type().is_some_and(|t| t.is_file());
244            if !is_file || !is_rule_file(path) {
245                continue;
246            }
247            let yaml = read_to_string(path).map_err(|e| RuleError::io(path, e))?;
248            let relative = path.strip_prefix(dir).unwrap_or(path);
249            rules.push(parse_rule_at(&yaml, relative)?);
250        }
251    }
252    Ok(rules)
253}
254
255/// Whether a builtin rule takes part in a scan under `config`.
256///
257/// A rule listed in `rules.enable` is always active. Otherwise it must be
258/// enabled by default, belong to an active ruleset, and not be listed in
259/// `rules.disable`.
260pub fn is_rule_active(rule: &Rule, config: &crate::config::Config) -> bool {
261    let listed = |ids: &[String]| ids.iter().any(|id| id == rule.id.as_str());
262    if listed(&config.rules.enable) {
263        return true;
264    }
265    let ruleset_on = match rule.effective_category() {
266        Category::Slop => config.rulesets.slop,
267        Category::Security => config.rulesets.security,
268        Category::Correctness => config.rulesets.correctness,
269    };
270    ruleset_on && rule.enabled && !listed(&config.rules.disable)
271}
272
273/// Load all effective rules based on config: builtin (filtered by rulesets and
274/// the enable/disable lists) plus custom rules from configured directories.
275pub fn load_effective_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
276    let mut rules: Vec<Rule> = load_builtin_rules()?
277        .into_iter()
278        .filter(|rule| is_rule_active(rule, config))
279        .collect();
280
281    let custom = load_custom_rules(&config.rules.custom_dirs)?;
282    rules.extend(custom);
283    validate_unique_ids(&rules)?;
284    Ok(rules)
285}
286
287/// Load all rules (builtin + custom) without filtering by activation status.
288/// Used by `explain` to look up any rule regardless of config.
289pub fn load_all_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
290    let mut rules = load_builtin_rules()?;
291    let custom = load_custom_rules(&config.rules.custom_dirs)?;
292    rules.extend(custom);
293    Ok(rules)
294}
295
296/// Validate that all rule (id, language) pairs are unique. The same rule id
297/// may appear for different languages (e.g. `no-todo-fixme` for both Rust and
298/// TypeScript), so a single `rules.disable` entry covers both variants.
299pub fn validate_unique_ids(rules: &[Rule]) -> Result<(), RuleError> {
300    let mut seen = HashSet::new();
301    for rule in rules {
302        if !seen.insert((&rule.id, &rule.language)) {
303            return Err(RuleError::DuplicateId {
304                id: rule.id.clone(),
305            });
306        }
307    }
308    Ok(())
309}
310
311#[cfg(test)]
312mod tests {
313    use std::fs::{create_dir, write};
314
315    use tempfile::tempdir;
316
317    use super::*;
318
319    #[test]
320    fn parse_valid_rule_all_fields() {
321        let yaml = r#"
322id: test-rule
323language: rust
324severity: error
325message: "Test message"
326note: "Test note"
327category: security
328fix: "Use X instead"
329rule:
330  kind: call_expression
331  regex: '\.unwrap\(\)\s*$'
332files:
333  - "**/src/**/*.rs"
334ignores:
335  - "**/tests/**"
336skip_test_code: true
337tests:
338  should_match:
339    - "let x = foo().unwrap();"
340  should_not_match:
341    - "let x = foo()?;"
342"#;
343        let rule = parse_rule(yaml).unwrap();
344        assert!(rule.skip_test_code);
345        assert_eq!(rule.id, RuleId::from("test-rule"));
346        assert_eq!(rule.language, Language::Rust);
347        assert_eq!(rule.severity, Severity::Error);
348        assert_eq!(rule.message, "Test message");
349        assert_eq!(rule.note.as_deref(), Some("Test note"));
350        assert_eq!(rule.category, Some(Category::Security));
351        assert_eq!(rule.fix.as_deref(), Some("Use X instead"));
352        assert!(!rule.rule.is_null());
353        assert_eq!(rule.files.as_ref().unwrap().len(), 1);
354        assert_eq!(rule.ignores.as_ref().unwrap().len(), 1);
355        let tests = rule.tests.as_ref().unwrap();
356        assert_eq!(tests.should_match.len(), 1);
357        assert_eq!(tests.should_not_match.len(), 1);
358    }
359
360    #[test]
361    fn parse_rule_with_ai_check() {
362        let yaml = r#"
363id: ai-rule
364language: rust
365severity: warning
366category: slop
367message: "needs AI confirmation"
368rule:
369  kind: line_comment
370  regex: 'SAFETY'
371ai_check:
372  prompt: "Is this SAFETY comment meaningful?\n{{code}}"
373  model: "claude-haiku-4-5"
374"#;
375        let rule = parse_rule(yaml).unwrap();
376        let ai = rule.ai_check.as_ref().expect("ai_check should be present");
377        assert_eq!(ai.prompt, "Is this SAFETY comment meaningful?\n{{code}}");
378        assert_eq!(ai.model.as_deref(), Some("claude-haiku-4-5"));
379    }
380
381    #[test]
382    fn parse_rule_ai_check_without_model() {
383        let yaml = r#"
384id: ai-rule-no-model
385language: rust
386severity: warning
387category: slop
388message: "needs AI confirmation"
389rule:
390  kind: line_comment
391ai_check:
392  prompt: "check {{filename}}"
393"#;
394        let rule = parse_rule(yaml).unwrap();
395        let ai = rule.ai_check.as_ref().unwrap();
396        assert_eq!(ai.prompt, "check {{filename}}");
397        assert!(
398            ai.model.is_none(),
399            "model falls back to config when omitted"
400        );
401    }
402
403    #[test]
404    fn parse_minimal_rule() {
405        let yaml = r#"
406id: minimal-rule
407language: rust
408severity: warning
409message: "Minimal"
410rule:
411  pattern: $X.unwrap()
412"#;
413        let rule = parse_rule(yaml).unwrap();
414        assert_eq!(rule.id, RuleId::from("minimal-rule"));
415        assert_eq!(rule.severity, Severity::Warning);
416        assert!(rule.note.is_none());
417        assert!(rule.category.is_none());
418        assert!(rule.fix.is_none());
419        assert!(rule.files.is_none());
420        assert!(rule.ignores.is_none());
421        assert!(!rule.skip_test_code);
422        assert!(rule.tests.is_none());
423        assert!(rule.enabled);
424        assert!(rule.ai_check.is_none());
425    }
426
427    #[test]
428    fn rule_activation_precedence() {
429        let opt_in = parse_rule(
430            r#"
431id: opt-in
432language: rust
433severity: warning
434category: slop
435enabled: false
436message: "Opt-in"
437rule:
438  pattern: $X.unwrap()
439"#,
440        )
441        .unwrap();
442        let mut config = crate::config::Config::default();
443
444        assert!(!is_rule_active(&opt_in, &config), "enabled: false is off");
445
446        config.rules.enable.push("opt-in".to_string());
447        assert!(is_rule_active(&opt_in, &config), "enable list turns it on");
448
449        config.rules.disable.push("opt-in".to_string());
450        assert!(is_rule_active(&opt_in, &config), "enable wins over disable");
451
452        config.rulesets.slop = false;
453        assert!(is_rule_active(&opt_in, &config), "enable wins over ruleset");
454
455        config.rules.enable.clear();
456        config.rules.disable.clear();
457        config.rulesets.slop = true;
458        let mut default_on = opt_in.clone();
459        default_on.enabled = true;
460        assert!(is_rule_active(&default_on, &config));
461
462        config.rules.disable.push("opt-in".to_string());
463        assert!(!is_rule_active(&default_on, &config), "disable list");
464
465        config.rules.disable.clear();
466        config.rulesets.slop = false;
467        assert!(!is_rule_active(&default_on, &config), "ruleset off");
468    }
469
470    #[test]
471    fn reject_missing_id() {
472        let yaml = r#"
473language: rust
474severity: error
475message: "No id"
476rule:
477  pattern: $X
478"#;
479        let err = parse_rule(yaml).unwrap_err();
480        let msg = err.to_string();
481        assert!(
482            msg.contains("missing field"),
483            "expected missing field error, got: {msg}"
484        );
485    }
486
487    #[test]
488    fn reject_unsupported_language() {
489        let yaml = r#"
490id: bad-lang
491language: python
492severity: error
493message: "Bad lang"
494rule:
495  pattern: $X
496"#;
497        let err = parse_rule(yaml).unwrap_err();
498        assert!(matches!(err, RuleError::Parse(_)));
499        let msg = err.to_string();
500        assert!(
501            msg.contains("unknown variant"),
502            "expected unknown variant error, got: {msg}"
503        );
504    }
505
506    #[test]
507    fn reject_empty_rule() {
508        let yaml = r#"
509id: empty-rule
510language: rust
511severity: error
512message: "Empty rule"
513rule:
514"#;
515        let err = parse_rule(yaml).unwrap_err();
516        let msg = err.to_string();
517        assert!(
518            msg.contains("must not be empty"),
519            "expected empty rule error, got: {msg}"
520        );
521    }
522
523    #[test]
524    fn reject_malformed_yaml() {
525        let yaml = "this is: [not: valid: yaml: {{{";
526        let err = parse_rule(yaml).unwrap_err();
527        assert!(matches!(err, RuleError::Parse(_)));
528    }
529
530    #[test]
531    fn load_all_builtin_rules() {
532        let rules = load_builtin_rules().unwrap();
533        assert_eq!(
534            rules.len(),
535            86,
536            "expected 86 builtin rules, got {}",
537            rules.len()
538        );
539
540        let slop_count = rules
541            .iter()
542            .filter(|r| r.category == Some(Category::Slop))
543            .count();
544        let security_count = rules
545            .iter()
546            .filter(|r| r.category == Some(Category::Security))
547            .count();
548        let correctness_count = rules
549            .iter()
550            .filter(|r| r.category == Some(Category::Correctness))
551            .count();
552        assert_eq!(slop_count, 28, "expected 28 slop rules");
553        assert_eq!(security_count, 20, "expected 20 security rules");
554        assert_eq!(correctness_count, 38, "expected 38 correctness rules");
555
556        assert!(rules
557            .iter()
558            .any(|r| r.id == RuleId::from("no-unwrap-in-prod")));
559        assert!(rules
560            .iter()
561            .any(|r| r.id == RuleId::from("no-debug-on-secrets")));
562        assert!(rules.iter().any(|r| r.id == RuleId::from("no-slop-words")));
563
564        validate_unique_ids(&rules).unwrap();
565    }
566
567    #[test]
568    fn load_custom_rules_from_dir() {
569        let dir = tempdir().unwrap();
570        let rule_yaml = r#"
571id: custom-rule
572language: rust
573severity: warning
574message: "Custom rule"
575rule:
576  pattern: $X.clone()
577"#;
578        write(dir.path().join("custom.yml"), rule_yaml).unwrap();
579
580        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
581        assert_eq!(rules.len(), 1);
582        assert_eq!(rules[0].id, RuleId::from("custom-rule"));
583    }
584
585    #[test]
586    fn reject_duplicate_ids_same_language() {
587        let rule1 = parse_rule(
588            r#"
589id: same-id
590language: rust
591severity: error
592message: "First"
593rule:
594  pattern: $X
595"#,
596        )
597        .unwrap();
598        let rule2 = parse_rule(
599            r#"
600id: same-id
601language: rust
602severity: warning
603message: "Second"
604rule:
605  pattern: $Y
606"#,
607        )
608        .unwrap();
609        let err = validate_unique_ids(&[rule1, rule2]).unwrap_err();
610        let msg = err.to_string();
611        assert!(
612            msg.contains("duplicate rule id"),
613            "expected duplicate id error, got: {msg}"
614        );
615        assert!(
616            msg.contains("same-id"),
617            "expected same-id in error, got: {msg}"
618        );
619    }
620
621    #[test]
622    fn allow_same_id_different_language() {
623        let rust_rule = parse_rule(
624            r#"
625id: shared-rule
626language: rust
627severity: error
628message: "Rust variant"
629rule:
630  pattern: $X
631"#,
632        )
633        .unwrap();
634        let ts_rule = parse_rule(
635            r#"
636id: shared-rule
637language: typescript
638severity: error
639message: "TypeScript variant"
640rule:
641  pattern: $X
642"#,
643        )
644        .unwrap();
645        validate_unique_ids(&[rust_rule, ts_rule]).unwrap();
646    }
647
648    #[test]
649    fn category_from_rule_path() {
650        let category = |p: &str| Category::from_rule_path(Path::new(p));
651        assert_eq!(category("slop/no-slop-words.yml"), Some(Category::Slop));
652        assert_eq!(category("security/no-debug.yml"), Some(Category::Security));
653        assert_eq!(
654            category("correctness/no-unwrap.yml"),
655            Some(Category::Correctness)
656        );
657        assert_eq!(category("unknown/something.yml"), None);
658        assert_eq!(category("rule.yml"), None);
659    }
660
661    #[test]
662    fn all_builtin_rules_valid_messages() {
663        for path in BuiltinRules::iter() {
664            if !is_rule_file(Path::new(path.as_ref())) {
665                continue;
666            }
667            let file = BuiltinRules::get(&path).unwrap();
668            let yaml = from_utf8(&file.data).unwrap();
669            let rule: Rule = serde_yaml::from_str(yaml).unwrap();
670            assert!(
671                !rule.message.contains('\u{2014}'),
672                "rule '{}' message contains em dash: {}",
673                rule.id,
674                rule.message
675            );
676            assert!(
677                !rule.message.contains('\u{2013}'),
678                "rule '{}' message contains en dash: {}",
679                rule.id,
680                rule.message
681            );
682            assert!(
683                rule.message.is_ascii() || rule.message.chars().all(|c| c.is_ascii() || c == '\''),
684                "rule '{}' message contains non-ASCII characters: {}",
685                rule.id,
686                rule.message
687            );
688        }
689    }
690
691    #[test]
692    fn all_builtin_rules_have_tests() {
693        for path in BuiltinRules::iter() {
694            if !is_rule_file(Path::new(path.as_ref())) {
695                continue;
696            }
697            let file = BuiltinRules::get(&path).unwrap();
698            let yaml = from_utf8(&file.data).unwrap();
699            let rule: Rule = serde_yaml::from_str(yaml).unwrap();
700            let tests = rule.tests.as_ref().unwrap_or_else(|| {
701                panic!("rule '{}' (at {path}) is missing `tests` block", rule.id)
702            });
703            assert!(
704                tests.should_match.len() >= 2,
705                "rule '{}' (at {path}) needs >= 2 should_match, got {}",
706                rule.id,
707                tests.should_match.len()
708            );
709            assert!(
710                tests.should_not_match.len() >= 2,
711                "rule '{}' (at {path}) needs >= 2 should_not_match, got {}",
712                rule.id,
713                tests.should_not_match.len()
714            );
715        }
716    }
717
718    #[test]
719    fn all_builtin_rules_have_explicit_category() {
720        for path in BuiltinRules::iter() {
721            if !is_rule_file(Path::new(path.as_ref())) {
722                continue;
723            }
724            let file = BuiltinRules::get(&path).unwrap();
725            let yaml = from_utf8(&file.data).unwrap();
726            let rule: Rule = serde_yaml::from_str(yaml).unwrap();
727            assert!(
728                rule.category.is_some(),
729                "rule '{}' (at {path}) is missing an explicit `category` field",
730                rule.id
731            );
732        }
733    }
734
735    #[test]
736    fn custom_rules_category_from_subdir() {
737        let dir = tempdir().unwrap();
738        let security_dir = dir.path().join("security");
739        create_dir(&security_dir).unwrap();
740        let rule_yaml = r#"
741id: custom-sec
742language: rust
743severity: error
744message: "Custom security"
745rule:
746  pattern: $X
747"#;
748        write(security_dir.join("rule.yml"), rule_yaml).unwrap();
749
750        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
751        assert_eq!(rules.len(), 1);
752        assert_eq!(rules[0].category, Some(Category::Security));
753    }
754}