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
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
184pub 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
193fn 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
210pub 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
225pub 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 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
247pub 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
265pub 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
279pub 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
288pub 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 59,
528 "expected 59 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, 18, "expected 18 slop rules");
545 assert_eq!(security_count, 12, "expected 12 security rules");
546 assert_eq!(correctness_count, 29, "expected 29 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}