1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4
5#[derive(Debug)]
11pub struct RatchetRule {
12 id: String,
13 severity: Severity,
14 message: String,
15 suggest: Option<String>,
16 glob: Option<String>,
17 pattern: String,
18 max_count: usize,
19 compiled_regex: Option<Regex>,
20}
21
22impl RatchetRule {
23 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
24 let pattern = config
25 .pattern
26 .as_ref()
27 .filter(|p| !p.is_empty())
28 .ok_or_else(|| RuleBuildError::MissingField(config.id.clone(), "pattern"))?
29 .clone();
30
31 let max_count = config
32 .max_count
33 .ok_or_else(|| RuleBuildError::MissingField(config.id.clone(), "max_count"))?;
34
35 let compiled_regex = if config.regex {
36 let re = Regex::new(&pattern)
37 .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
38 Some(re)
39 } else {
40 None
41 };
42
43 Ok(Self {
44 id: config.id.clone(),
45 severity: config.severity,
46 message: config.message.clone(),
47 suggest: config.suggest.clone(),
48 glob: config.glob.clone(),
49 pattern,
50 max_count,
51 compiled_regex,
52 })
53 }
54
55 pub fn max_count(&self) -> usize {
56 self.max_count
57 }
58
59 pub fn pattern(&self) -> &str {
60 &self.pattern
61 }
62}
63
64impl Rule for RatchetRule {
65 fn id(&self) -> &str {
66 &self.id
67 }
68
69 fn severity(&self) -> Severity {
70 self.severity
71 }
72
73 fn file_glob(&self) -> Option<&str> {
74 self.glob.as_deref()
75 }
76
77 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
78 let mut violations = Vec::new();
79
80 for (line_idx, line) in ctx.content.lines().enumerate() {
81 if let Some(ref re) = self.compiled_regex {
82 for m in re.find_iter(line) {
84 violations.push(Violation {
85 rule_id: self.id.clone(),
86 severity: self.severity,
87 file: ctx.file_path.to_path_buf(),
88 line: Some(line_idx + 1),
89 column: Some(m.start() + 1),
90 message: self.message.clone(),
91 suggest: self.suggest.clone(),
92 source_line: Some(line.to_string()),
93 fix: None,
94 });
95 }
96 } else {
97 let pattern = self.pattern.as_str();
99 let pattern_len = pattern.len();
100 let mut search_start = 0;
101 while let Some(pos) = line[search_start..].find(pattern) {
102 let col = search_start + pos;
103 violations.push(Violation {
104 rule_id: self.id.clone(),
105 severity: self.severity,
106 file: ctx.file_path.to_path_buf(),
107 line: Some(line_idx + 1),
108 column: Some(col + 1),
109 message: self.message.clone(),
110 suggest: self.suggest.clone(),
111 source_line: Some(line.to_string()),
112 fix: None,
113 });
114 search_start = col + pattern_len;
115 }
116 }
117 }
118
119 violations
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use std::path::Path;
127
128 fn make_config(pattern: Option<&str>, max_count: Option<usize>) -> RuleConfig {
129 RuleConfig {
130 id: "test-ratchet".into(),
131 severity: Severity::Error,
132 message: "legacy pattern found".into(),
133 suggest: Some("use newApi() instead".into()),
134 pattern: pattern.map(|s| s.to_string()),
135 max_count,
136 ..Default::default()
137 }
138 }
139
140 #[test]
141 fn basic_match() {
142 let config = make_config(Some("legacyFetch("), Some(10));
143 let rule = RatchetRule::new(&config).unwrap();
144 let content = "let x = legacyFetch(url);\nlet y = newFetch(url);";
145 let ctx = ScanContext {
146 file_path: Path::new("test.ts"),
147 content,
148 };
149 let violations = rule.check_file(&ctx);
150 assert_eq!(violations.len(), 1);
151 assert_eq!(violations[0].line, Some(1));
152 assert_eq!(violations[0].column, Some(9));
153 }
154
155 #[test]
156 fn multiple_matches_per_line() {
157 let config = make_config(Some("TODO"), Some(5));
158 let rule = RatchetRule::new(&config).unwrap();
159 let content = "// TODO fix this TODO and that TODO";
160 let ctx = ScanContext {
161 file_path: Path::new("test.ts"),
162 content,
163 };
164 let violations = rule.check_file(&ctx);
165 assert_eq!(violations.len(), 3);
166 assert_eq!(violations[0].column, Some(4));
167 assert_eq!(violations[1].column, Some(18));
168 assert_eq!(violations[2].column, Some(32));
169 }
170
171 #[test]
172 fn no_matches() {
173 let config = make_config(Some("legacyFetch("), Some(0));
174 let rule = RatchetRule::new(&config).unwrap();
175 let content = "let x = apiFetch(url);";
176 let ctx = ScanContext {
177 file_path: Path::new("test.ts"),
178 content,
179 };
180 let violations = rule.check_file(&ctx);
181 assert!(violations.is_empty());
182 }
183
184 #[test]
185 fn column_accuracy() {
186 let config = make_config(Some("bad("), Some(10));
187 let rule = RatchetRule::new(&config).unwrap();
188 let content = " bad(x)";
189 let ctx = ScanContext {
190 file_path: Path::new("test.ts"),
191 content,
192 };
193 let violations = rule.check_file(&ctx);
194 assert_eq!(violations.len(), 1);
195 assert_eq!(violations[0].column, Some(5)); }
197
198 #[test]
199 fn missing_pattern_error() {
200 let config = make_config(None, Some(10));
201 let err = RatchetRule::new(&config).unwrap_err();
202 assert!(
203 matches!(err, RuleBuildError::MissingField(_, "pattern")),
204 "expected MissingField for pattern, got {:?}",
205 err
206 );
207 }
208
209 #[test]
210 fn empty_pattern_error() {
211 let config = make_config(Some(""), Some(10));
212 let err = RatchetRule::new(&config).unwrap_err();
213 assert!(matches!(err, RuleBuildError::MissingField(_, "pattern")));
214 }
215
216 #[test]
217 fn missing_max_count_error() {
218 let config = make_config(Some("TODO"), None);
219 let err = RatchetRule::new(&config).unwrap_err();
220 assert!(matches!(err, RuleBuildError::MissingField(_, "max_count")));
221 }
222
223 #[test]
224 fn max_count_zero_works() {
225 let config = make_config(Some("bad"), Some(0));
226 let rule = RatchetRule::new(&config).unwrap();
227 assert_eq!(rule.max_count(), 0);
228 }
229
230 #[test]
231 fn accessors() {
232 let config = make_config(Some("legacyFetch("), Some(47));
233 let rule = RatchetRule::new(&config).unwrap();
234 assert_eq!(rule.pattern(), "legacyFetch(");
235 assert_eq!(rule.max_count(), 47);
236 assert_eq!(rule.id(), "test-ratchet");
237 }
238}