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 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 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 #[serde(default)]
104 pub should_match_files: Vec<String>,
105 #[serde(default)]
106 pub should_not_match_files: Vec<String>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct AiCheck {
115 pub prompt: String,
118 #[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 #[serde(default)]
143 pub metric: Option<Metric>,
144 #[serde(default)]
146 pub threshold: Option<f64>,
147 #[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 #[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 #[serde(default)]
166 pub ai_check: Option<AiCheck>,
167}
168
169impl Rule {
170 pub fn effective_category(&self) -> Category {
173 self.category.clone().unwrap_or_default()
174 }
175
176 pub fn is_metric(&self) -> bool {
178 self.metric.is_some()
179 }
180
181 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
237pub 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
261pub 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
303fn 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
320pub 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
335pub 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 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
359pub 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
377pub 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
391pub 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
400pub 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 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}