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