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