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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct AiCheck {
106 pub prompt: String,
109 #[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 #[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 #[serde(default)]
146 pub ai_check: Option<AiCheck>,
147}
148
149impl Rule {
150 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
192pub 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
201fn 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
218pub 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
233pub 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 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
255pub 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
273pub 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
287pub 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
296pub 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}