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, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
113#[serde(rename_all = "lowercase")]
114#[strum(serialize_all = "lowercase")]
115pub enum ReasonMode {
116 #[default]
118 Static,
119 Generated,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct AiCheck {
131 pub prompt: String,
135 #[serde(default)]
137 pub model: Option<String>,
138 #[serde(default)]
141 pub reason: ReasonMode,
142 #[serde(default)]
145 pub threshold: Option<f64>,
146 #[serde(default)]
149 pub if_true: Option<String>,
150 #[serde(default)]
153 pub if_false: Option<String>,
154}
155
156fn default_enabled() -> bool {
157 true
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct Rule {
162 pub id: RuleId,
163 pub language: Language,
164 pub severity: Severity,
165 pub message: String,
166 #[serde(default)]
167 pub note: Option<String>,
168 #[serde(default)]
169 pub category: Option<Category>,
170 #[serde(default)]
171 pub fix: Option<String>,
172 #[serde(default)]
173 pub rule: Value,
174 #[serde(default)]
176 pub metric: Option<Metric>,
177 #[serde(default)]
179 pub threshold: Option<f64>,
180 #[serde(default)]
184 pub min_lines: Option<usize>,
185 #[serde(default)]
189 pub cross_file: Option<CrossFileKind>,
190 #[serde(skip)]
193 pub source_dir: Option<PathBuf>,
194 #[serde(default)]
195 pub files: Option<Vec<String>>,
196 #[serde(default)]
197 pub ignores: Option<Vec<String>>,
198 #[serde(default = "default_enabled")]
201 pub enabled: bool,
202 #[serde(default)]
203 pub skip_test_code: bool,
204 #[serde(default)]
205 pub tests: Option<RuleTests>,
206 #[serde(default)]
209 pub ai_check: Option<AiCheck>,
210}
211
212impl Rule {
213 pub fn effective_category(&self) -> Category {
216 self.category.clone().unwrap_or_default()
217 }
218
219 pub fn is_metric(&self) -> bool {
221 self.metric.is_some()
222 }
223
224 pub fn metric_spec(&self) -> Option<(Metric, f64)> {
226 Some((self.metric?, self.threshold?))
227 }
228
229 pub fn is_cross_file(&self) -> bool {
231 self.cross_file.is_some()
232 }
233
234 pub fn cross_file_kind(&self) -> Option<CrossFileKind> {
238 self.cross_file
239 }
240}
241
242#[derive(Debug, Error)]
243pub enum RuleError {
244 #[error("failed to parse rule YAML: {0}")]
245 Parse(#[from] serde_yaml::Error),
246
247 #[error("rule '{id}': rule field must not be empty")]
248 EmptyRule { id: RuleId },
249
250 #[error("rule '{id}': 'metric' and 'rule' are mutually exclusive")]
251 MetricAndRule { id: RuleId },
252
253 #[error("rule '{id}': 'cross_file' and 'rule' are mutually exclusive")]
254 CrossFileAndRule { id: RuleId },
255
256 #[error("rule '{id}': 'cross_file' and 'metric' are mutually exclusive")]
257 CrossFileAndMetric { id: RuleId },
258
259 #[error("rule '{id}': a metric rule requires a 'threshold'")]
260 MissingThreshold { id: RuleId },
261
262 #[error("rule '{id}': test fixture files are only supported for metric rules")]
263 FixturesOnAstRule { id: RuleId },
264
265 #[error("rule '{id}': fixture path '{path}' must be relative and must not contain '..'")]
266 InvalidFixturePath { id: RuleId, path: String },
267
268 #[error("rule '{id}': fixture '{path}' not found")]
269 FixtureNotFound { id: RuleId, path: String },
270
271 #[error("duplicate rule id: '{id}'")]
272 DuplicateId { id: RuleId },
273
274 #[error("failed to read rule file '{path}': {source}")]
275 Io {
276 path: String,
277 #[source]
278 source: IoError,
279 },
280
281 #[error("rule file '{path}' is not valid UTF-8: {source}")]
282 Utf8 {
283 path: String,
284 #[source]
285 source: Utf8Error,
286 },
287}
288
289impl RuleError {
290 fn io(path: &Path, source: IoError) -> Self {
291 Self::Io {
292 path: path.display().to_string(),
293 source,
294 }
295 }
296}
297
298pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
303 let rule: Rule = serde_yaml::from_str(yaml)?;
304 let has_rule = !rule.rule.is_null();
305 let has_metric = rule.metric.is_some();
306 let has_cross = rule.cross_file.is_some();
307 match (has_cross, has_metric, has_rule) {
308 (true, _, true) => return Err(RuleError::CrossFileAndRule { id: rule.id }),
309 (true, true, _) => return Err(RuleError::CrossFileAndMetric { id: rule.id }),
310 (false, true, true) => return Err(RuleError::MetricAndRule { id: rule.id }),
311 (false, true, false) if rule.threshold.is_none() => {
312 return Err(RuleError::MissingThreshold { id: rule.id })
313 }
314 (false, false, false) => return Err(RuleError::EmptyRule { id: rule.id }),
315 _ => {}
316 }
317 if !rule.is_metric() {
318 if let Some(tests) = &rule.tests {
319 if !tests.should_match_files.is_empty() || !tests.should_not_match_files.is_empty() {
320 return Err(RuleError::FixturesOnAstRule { id: rule.id });
321 }
322 }
323 }
324 Ok(rule)
325}
326
327pub fn read_fixture(rule: &Rule, relative: &str) -> Result<String, RuleError> {
331 let path = Path::new(relative);
332 let traverses = path.components().any(|c| matches!(c, Component::ParentDir));
333 if path.is_absolute() || traverses {
334 return Err(RuleError::InvalidFixturePath {
335 id: rule.id.clone(),
336 path: relative.to_string(),
337 });
338 }
339
340 match &rule.source_dir {
341 Some(dir) => {
342 let full = dir.join(path);
343 read_to_string(&full).map_err(|e| {
344 if e.kind() == std::io::ErrorKind::NotFound {
345 RuleError::FixtureNotFound {
346 id: rule.id.clone(),
347 path: relative.to_string(),
348 }
349 } else {
350 RuleError::io(&full, e)
351 }
352 })
353 }
354 None => {
355 let file = BuiltinRules::get(relative).ok_or_else(|| RuleError::FixtureNotFound {
356 id: rule.id.clone(),
357 path: relative.to_string(),
358 })?;
359 from_utf8(&file.data)
360 .map(str::to_string)
361 .map_err(|e| RuleError::Utf8 {
362 path: relative.to_string(),
363 source: e,
364 })
365 }
366 }
367}
368
369fn parse_rule_at(yaml: &str, relative_path: &Path) -> Result<Rule, RuleError> {
372 let mut rule = parse_rule(yaml)?;
373 if rule.category.is_none() {
374 rule.category = Category::from_rule_path(relative_path);
375 }
376 Ok(rule)
377}
378
379fn is_rule_file(path: &Path) -> bool {
380 matches!(
381 path.extension().and_then(|e| e.to_str()),
382 Some("yml" | "yaml")
383 )
384}
385
386pub fn load_builtin_rules() -> Result<Vec<Rule>, RuleError> {
388 BuiltinRules::iter()
389 .filter(|path| is_rule_file(Path::new(path.as_ref())))
390 .filter_map(|path| BuiltinRules::get(&path).map(|file| (path, file)))
391 .map(|(path, file)| {
392 let yaml = from_utf8(&file.data).map_err(|e| RuleError::Utf8 {
393 path: path.to_string(),
394 source: e,
395 })?;
396 parse_rule_at(yaml, Path::new(path.as_ref()))
397 })
398 .collect()
399}
400
401pub fn load_custom_rules(dirs: &[PathBuf]) -> Result<Vec<Rule>, RuleError> {
404 let mut rules = Vec::new();
405 for dir in dirs.iter().filter(|dir| dir.is_dir()) {
406 for entry in WalkBuilder::new(dir).standard_filters(false).build() {
409 let entry = entry.map_err(|e| RuleError::io(dir, IoError::other(e)))?;
410 let path = entry.path();
411 let is_file = entry.file_type().is_some_and(|t| t.is_file());
412 if !is_file || !is_rule_file(path) {
413 continue;
414 }
415 let yaml = read_to_string(path).map_err(|e| RuleError::io(path, e))?;
416 let relative = path.strip_prefix(dir).unwrap_or(path);
417 let mut rule = parse_rule_at(&yaml, relative)?;
418 rule.source_dir = Some(dir.clone());
419 rules.push(rule);
420 }
421 }
422 Ok(rules)
423}
424
425pub fn is_rule_active(rule: &Rule, config: &crate::config::Config) -> bool {
431 let listed = |ids: &[String]| ids.iter().any(|id| id == rule.id.as_str());
432 if listed(&config.rules.enable) {
433 return true;
434 }
435 let ruleset_on = match rule.effective_category() {
436 Category::Slop => config.rulesets.slop,
437 Category::Security => config.rulesets.security,
438 Category::Correctness => config.rulesets.correctness,
439 };
440 ruleset_on && rule.enabled && !listed(&config.rules.disable)
441}
442
443pub fn load_effective_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
446 let mut rules: Vec<Rule> = load_builtin_rules()?
447 .into_iter()
448 .filter(|rule| is_rule_active(rule, config))
449 .collect();
450
451 let custom = load_custom_rules(&config.rules.custom_dirs)?;
452 rules.extend(custom);
453 validate_unique_ids(&rules)?;
454 Ok(rules)
455}
456
457pub fn load_all_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
460 let mut rules = load_builtin_rules()?;
461 let custom = load_custom_rules(&config.rules.custom_dirs)?;
462 rules.extend(custom);
463 Ok(rules)
464}
465
466pub fn validate_unique_ids(rules: &[Rule]) -> Result<(), RuleError> {
470 let mut seen = HashSet::new();
471 for rule in rules {
472 if !seen.insert((&rule.id, &rule.language)) {
473 return Err(RuleError::DuplicateId {
474 id: rule.id.clone(),
475 });
476 }
477 }
478 Ok(())
479}
480
481#[cfg(test)]
482mod load_tests;
483#[cfg(test)]
484mod parse_tests;