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