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, 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
100fn default_enabled() -> bool {
101 true
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct Rule {
106 pub id: RuleId,
107 pub language: Language,
108 pub severity: Severity,
109 pub message: String,
110 #[serde(default)]
111 pub note: Option<String>,
112 #[serde(default)]
113 pub category: Option<Category>,
114 #[serde(default)]
115 pub fix: Option<String>,
116 pub rule: Value,
117 #[serde(default)]
118 pub files: Option<Vec<String>>,
119 #[serde(default)]
120 pub ignores: Option<Vec<String>>,
121 #[serde(default = "default_enabled")]
124 pub enabled: bool,
125 #[serde(default)]
126 pub skip_test_code: bool,
127 #[serde(default)]
128 pub tests: Option<RuleTests>,
129}
130
131#[derive(Debug, Error)]
132pub enum RuleError {
133 #[error("failed to parse rule YAML: {0}")]
134 Parse(#[from] serde_yaml::Error),
135
136 #[error("rule '{id}': rule field must not be empty")]
137 EmptyRule { id: RuleId },
138
139 #[error("duplicate rule id: '{id}'")]
140 DuplicateId { id: RuleId },
141
142 #[error("failed to read rule file '{path}': {source}")]
143 Io {
144 path: String,
145 #[source]
146 source: IoError,
147 },
148
149 #[error("rule file '{path}' is not valid UTF-8: {source}")]
150 Utf8 {
151 path: String,
152 #[source]
153 source: Utf8Error,
154 },
155}
156
157impl RuleError {
158 fn io(path: &Path, source: IoError) -> Self {
159 Self::Io {
160 path: path.display().to_string(),
161 source,
162 }
163 }
164}
165
166pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
168 let rule: Rule = serde_yaml::from_str(yaml)?;
169 if rule.rule.is_null() {
170 return Err(RuleError::EmptyRule { id: rule.id });
171 }
172 Ok(rule)
173}
174
175fn parse_rule_at(yaml: &str, relative_path: &Path) -> Result<Rule, RuleError> {
178 let mut rule = parse_rule(yaml)?;
179 if rule.category.is_none() {
180 rule.category = Category::from_rule_path(relative_path);
181 }
182 Ok(rule)
183}
184
185fn is_rule_file(path: &Path) -> bool {
186 matches!(
187 path.extension().and_then(|e| e.to_str()),
188 Some("yml" | "yaml")
189 )
190}
191
192pub fn load_builtin_rules() -> Result<Vec<Rule>, RuleError> {
194 BuiltinRules::iter()
195 .filter(|path| is_rule_file(Path::new(path.as_ref())))
196 .filter_map(|path| BuiltinRules::get(&path).map(|file| (path, file)))
197 .map(|(path, file)| {
198 let yaml = from_utf8(&file.data).map_err(|e| RuleError::Utf8 {
199 path: path.to_string(),
200 source: e,
201 })?;
202 parse_rule_at(yaml, Path::new(path.as_ref()))
203 })
204 .collect()
205}
206
207pub fn load_custom_rules(dirs: &[PathBuf]) -> Result<Vec<Rule>, RuleError> {
210 let mut rules = Vec::new();
211 for dir in dirs.iter().filter(|dir| dir.is_dir()) {
212 for entry in WalkBuilder::new(dir).standard_filters(false).build() {
215 let entry = entry.map_err(|e| RuleError::io(dir, IoError::other(e)))?;
216 let path = entry.path();
217 let is_file = entry.file_type().is_some_and(|t| t.is_file());
218 if !is_file || !is_rule_file(path) {
219 continue;
220 }
221 let yaml = read_to_string(path).map_err(|e| RuleError::io(path, e))?;
222 let relative = path.strip_prefix(dir).unwrap_or(path);
223 rules.push(parse_rule_at(&yaml, relative)?);
224 }
225 }
226 Ok(rules)
227}
228
229pub fn is_rule_active(rule: &Rule, config: &crate::config::Config) -> bool {
235 let listed = |ids: &[String]| ids.iter().any(|id| id == rule.id.as_str());
236 if listed(&config.rules.enable) {
237 return true;
238 }
239 let ruleset_on = match rule.category.as_ref().unwrap_or(&Category::Correctness) {
240 Category::Slop => config.rulesets.slop,
241 Category::Security => config.rulesets.security,
242 Category::Correctness => config.rulesets.correctness,
243 };
244 ruleset_on && rule.enabled && !listed(&config.rules.disable)
245}
246
247pub fn load_effective_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
250 let mut rules: Vec<Rule> = load_builtin_rules()?
251 .into_iter()
252 .filter(|rule| is_rule_active(rule, config))
253 .collect();
254
255 let custom = load_custom_rules(&config.rules.custom_dirs)?;
256 rules.extend(custom);
257 validate_unique_ids(&rules)?;
258 Ok(rules)
259}
260
261pub fn load_all_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
264 let mut rules = load_builtin_rules()?;
265 let custom = load_custom_rules(&config.rules.custom_dirs)?;
266 rules.extend(custom);
267 Ok(rules)
268}
269
270pub fn validate_unique_ids(rules: &[Rule]) -> Result<(), RuleError> {
272 let mut seen = HashSet::new();
273 for rule in rules {
274 if !seen.insert(&rule.id) {
275 return Err(RuleError::DuplicateId {
276 id: rule.id.clone(),
277 });
278 }
279 }
280 Ok(())
281}
282
283#[cfg(test)]
284mod tests {
285 use std::fs::{create_dir, write};
286
287 use tempfile::tempdir;
288
289 use super::*;
290
291 #[test]
292 fn parse_valid_rule_all_fields() {
293 let yaml = r#"
294id: test-rule
295language: rust
296severity: error
297message: "Test message"
298note: "Test note"
299category: security
300fix: "Use X instead"
301rule:
302 kind: call_expression
303 regex: '\.unwrap\(\)\s*$'
304files:
305 - "**/src/**/*.rs"
306ignores:
307 - "**/tests/**"
308skip_test_code: true
309tests:
310 should_match:
311 - "let x = foo().unwrap();"
312 should_not_match:
313 - "let x = foo()?;"
314"#;
315 let rule = parse_rule(yaml).unwrap();
316 assert!(rule.skip_test_code);
317 assert_eq!(rule.id, RuleId::from("test-rule"));
318 assert_eq!(rule.language, Language::Rust);
319 assert_eq!(rule.severity, Severity::Error);
320 assert_eq!(rule.message, "Test message");
321 assert_eq!(rule.note.as_deref(), Some("Test note"));
322 assert_eq!(rule.category, Some(Category::Security));
323 assert_eq!(rule.fix.as_deref(), Some("Use X instead"));
324 assert!(!rule.rule.is_null());
325 assert_eq!(rule.files.as_ref().unwrap().len(), 1);
326 assert_eq!(rule.ignores.as_ref().unwrap().len(), 1);
327 let tests = rule.tests.as_ref().unwrap();
328 assert_eq!(tests.should_match.len(), 1);
329 assert_eq!(tests.should_not_match.len(), 1);
330 }
331
332 #[test]
333 fn parse_minimal_rule() {
334 let yaml = r#"
335id: minimal-rule
336language: rust
337severity: warning
338message: "Minimal"
339rule:
340 pattern: $X.unwrap()
341"#;
342 let rule = parse_rule(yaml).unwrap();
343 assert_eq!(rule.id, RuleId::from("minimal-rule"));
344 assert_eq!(rule.severity, Severity::Warning);
345 assert!(rule.note.is_none());
346 assert!(rule.category.is_none());
347 assert!(rule.fix.is_none());
348 assert!(rule.files.is_none());
349 assert!(rule.ignores.is_none());
350 assert!(!rule.skip_test_code);
351 assert!(rule.tests.is_none());
352 assert!(rule.enabled);
353 }
354
355 #[test]
356 fn rule_activation_precedence() {
357 let opt_in = parse_rule(
358 r#"
359id: opt-in
360language: rust
361severity: warning
362category: slop
363enabled: false
364message: "Opt-in"
365rule:
366 pattern: $X.unwrap()
367"#,
368 )
369 .unwrap();
370 let mut config = crate::config::Config::default();
371
372 assert!(!is_rule_active(&opt_in, &config), "enabled: false is off");
373
374 config.rules.enable.push("opt-in".to_string());
375 assert!(is_rule_active(&opt_in, &config), "enable list turns it on");
376
377 config.rules.disable.push("opt-in".to_string());
378 assert!(is_rule_active(&opt_in, &config), "enable wins over disable");
379
380 config.rulesets.slop = false;
381 assert!(is_rule_active(&opt_in, &config), "enable wins over ruleset");
382
383 config.rules.enable.clear();
384 config.rules.disable.clear();
385 config.rulesets.slop = true;
386 let mut default_on = opt_in.clone();
387 default_on.enabled = true;
388 assert!(is_rule_active(&default_on, &config));
389
390 config.rules.disable.push("opt-in".to_string());
391 assert!(!is_rule_active(&default_on, &config), "disable list");
392
393 config.rules.disable.clear();
394 config.rulesets.slop = false;
395 assert!(!is_rule_active(&default_on, &config), "ruleset off");
396 }
397
398 #[test]
399 fn reject_missing_id() {
400 let yaml = r#"
401language: rust
402severity: error
403message: "No id"
404rule:
405 pattern: $X
406"#;
407 let err = parse_rule(yaml).unwrap_err();
408 let msg = err.to_string();
409 assert!(
410 msg.contains("missing field"),
411 "expected missing field error, got: {msg}"
412 );
413 }
414
415 #[test]
416 fn reject_unsupported_language() {
417 let yaml = r#"
418id: bad-lang
419language: python
420severity: error
421message: "Bad lang"
422rule:
423 pattern: $X
424"#;
425 let err = parse_rule(yaml).unwrap_err();
426 assert!(matches!(err, RuleError::Parse(_)));
427 let msg = err.to_string();
428 assert!(
429 msg.contains("unknown variant"),
430 "expected unknown variant error, got: {msg}"
431 );
432 }
433
434 #[test]
435 fn reject_empty_rule() {
436 let yaml = r#"
437id: empty-rule
438language: rust
439severity: error
440message: "Empty rule"
441rule:
442"#;
443 let err = parse_rule(yaml).unwrap_err();
444 let msg = err.to_string();
445 assert!(
446 msg.contains("must not be empty"),
447 "expected empty rule error, got: {msg}"
448 );
449 }
450
451 #[test]
452 fn reject_malformed_yaml() {
453 let yaml = "this is: [not: valid: yaml: {{{";
454 let err = parse_rule(yaml).unwrap_err();
455 assert!(matches!(err, RuleError::Parse(_)));
456 }
457
458 #[test]
459 fn load_all_builtin_rules() {
460 let rules = load_builtin_rules().unwrap();
461 assert_eq!(
462 rules.len(),
463 34,
464 "expected 34 builtin rules, got {}",
465 rules.len()
466 );
467
468 let slop_count = rules
469 .iter()
470 .filter(|r| r.category == Some(Category::Slop))
471 .count();
472 let security_count = rules
473 .iter()
474 .filter(|r| r.category == Some(Category::Security))
475 .count();
476 let correctness_count = rules
477 .iter()
478 .filter(|r| r.category == Some(Category::Correctness))
479 .count();
480 assert_eq!(slop_count, 9, "expected 9 slop rules");
481 assert_eq!(security_count, 8, "expected 8 security rules");
482 assert_eq!(correctness_count, 17, "expected 17 correctness rules");
483
484 assert!(rules
485 .iter()
486 .any(|r| r.id == RuleId::from("no-unwrap-in-prod")));
487 assert!(rules
488 .iter()
489 .any(|r| r.id == RuleId::from("no-debug-on-secrets")));
490 assert!(rules.iter().any(|r| r.id == RuleId::from("no-slop-words")));
491
492 validate_unique_ids(&rules).unwrap();
493 }
494
495 #[test]
496 fn load_custom_rules_from_dir() {
497 let dir = tempdir().unwrap();
498 let rule_yaml = r#"
499id: custom-rule
500language: rust
501severity: warning
502message: "Custom rule"
503rule:
504 pattern: $X.clone()
505"#;
506 write(dir.path().join("custom.yml"), rule_yaml).unwrap();
507
508 let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
509 assert_eq!(rules.len(), 1);
510 assert_eq!(rules[0].id, RuleId::from("custom-rule"));
511 }
512
513 #[test]
514 fn reject_duplicate_ids() {
515 let rule1 = parse_rule(
516 r#"
517id: same-id
518language: rust
519severity: error
520message: "First"
521rule:
522 pattern: $X
523"#,
524 )
525 .unwrap();
526 let rule2 = parse_rule(
527 r#"
528id: same-id
529language: rust
530severity: warning
531message: "Second"
532rule:
533 pattern: $Y
534"#,
535 )
536 .unwrap();
537 let err = validate_unique_ids(&[rule1, rule2]).unwrap_err();
538 let msg = err.to_string();
539 assert!(
540 msg.contains("duplicate rule id"),
541 "expected duplicate id error, got: {msg}"
542 );
543 assert!(
544 msg.contains("same-id"),
545 "expected same-id in error, got: {msg}"
546 );
547 }
548
549 #[test]
550 fn category_from_rule_path() {
551 let category = |p: &str| Category::from_rule_path(Path::new(p));
552 assert_eq!(category("slop/no-slop-words.yml"), Some(Category::Slop));
553 assert_eq!(category("security/no-debug.yml"), Some(Category::Security));
554 assert_eq!(
555 category("correctness/no-unwrap.yml"),
556 Some(Category::Correctness)
557 );
558 assert_eq!(category("unknown/something.yml"), None);
559 assert_eq!(category("rule.yml"), None);
560 }
561
562 #[test]
563 fn all_builtin_rules_valid_messages() {
564 for path in BuiltinRules::iter() {
565 if !is_rule_file(Path::new(path.as_ref())) {
566 continue;
567 }
568 let file = BuiltinRules::get(&path).unwrap();
569 let yaml = from_utf8(&file.data).unwrap();
570 let rule: Rule = serde_yaml::from_str(yaml).unwrap();
571 assert!(
572 !rule.message.contains('\u{2014}'),
573 "rule '{}' message contains em dash: {}",
574 rule.id,
575 rule.message
576 );
577 assert!(
578 !rule.message.contains('\u{2013}'),
579 "rule '{}' message contains en dash: {}",
580 rule.id,
581 rule.message
582 );
583 assert!(
584 rule.message.is_ascii() || rule.message.chars().all(|c| c.is_ascii() || c == '\''),
585 "rule '{}' message contains non-ASCII characters: {}",
586 rule.id,
587 rule.message
588 );
589 }
590 }
591
592 #[test]
593 fn all_builtin_rules_have_tests() {
594 for path in BuiltinRules::iter() {
595 if !is_rule_file(Path::new(path.as_ref())) {
596 continue;
597 }
598 let file = BuiltinRules::get(&path).unwrap();
599 let yaml = from_utf8(&file.data).unwrap();
600 let rule: Rule = serde_yaml::from_str(yaml).unwrap();
601 let tests = rule.tests.as_ref().unwrap_or_else(|| {
602 panic!("rule '{}' (at {path}) is missing `tests` block", rule.id)
603 });
604 assert!(
605 tests.should_match.len() >= 2,
606 "rule '{}' (at {path}) needs >= 2 should_match, got {}",
607 rule.id,
608 tests.should_match.len()
609 );
610 assert!(
611 tests.should_not_match.len() >= 2,
612 "rule '{}' (at {path}) needs >= 2 should_not_match, got {}",
613 rule.id,
614 tests.should_not_match.len()
615 );
616 }
617 }
618
619 #[test]
620 fn all_builtin_rules_have_explicit_category() {
621 for path in BuiltinRules::iter() {
622 if !is_rule_file(Path::new(path.as_ref())) {
623 continue;
624 }
625 let file = BuiltinRules::get(&path).unwrap();
626 let yaml = from_utf8(&file.data).unwrap();
627 let rule: Rule = serde_yaml::from_str(yaml).unwrap();
628 assert!(
629 rule.category.is_some(),
630 "rule '{}' (at {path}) is missing an explicit `category` field",
631 rule.id
632 );
633 }
634 }
635
636 #[test]
637 fn custom_rules_category_from_subdir() {
638 let dir = tempdir().unwrap();
639 let security_dir = dir.path().join("security");
640 create_dir(&security_dir).unwrap();
641 let rule_yaml = r#"
642id: custom-sec
643language: rust
644severity: error
645message: "Custom security"
646rule:
647 pattern: $X
648"#;
649 write(security_dir.join("rule.yml"), rule_yaml).unwrap();
650
651 let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
652 assert_eq!(rules.len(), 1);
653 assert_eq!(rules[0].category, Some(Category::Security));
654 }
655}