1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4
5#[derive(Debug)]
18pub struct WindowPatternRule {
19 id: String,
20 severity: Severity,
21 message: String,
22 suggest: Option<String>,
23 glob: Option<String>,
24 trigger: String,
25 trigger_re: Option<Regex>,
26 required: String,
27 required_re: Option<Regex>,
28 window_size: usize,
29}
30
31impl WindowPatternRule {
32 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
33 let trigger = config
34 .pattern
35 .as_ref()
36 .filter(|p| !p.is_empty())
37 .ok_or_else(|| RuleBuildError::MissingField(config.id.clone(), "pattern"))?
38 .clone();
39
40 let required = config
41 .condition_pattern
42 .as_ref()
43 .filter(|p| !p.is_empty())
44 .ok_or_else(|| {
45 RuleBuildError::MissingField(config.id.clone(), "condition_pattern")
46 })?
47 .clone();
48
49 let window_size = config.max_count.unwrap_or(10);
50
51 let trigger_re = if config.regex {
52 Some(
53 Regex::new(&trigger)
54 .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?,
55 )
56 } else {
57 None
58 };
59
60 let required_re = if config.regex {
61 Some(
62 Regex::new(&required)
63 .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?,
64 )
65 } else {
66 None
67 };
68
69 Ok(Self {
70 id: config.id.clone(),
71 severity: config.severity,
72 message: config.message.clone(),
73 suggest: config.suggest.clone(),
74 glob: config.glob.clone(),
75 trigger,
76 trigger_re,
77 required,
78 required_re,
79 window_size,
80 })
81 }
82
83 fn line_matches_trigger(&self, line: &str) -> bool {
84 match &self.trigger_re {
85 Some(re) => re.is_match(line),
86 None => line.contains(&self.trigger),
87 }
88 }
89
90 fn line_matches_required(&self, line: &str) -> bool {
91 match &self.required_re {
92 Some(re) => re.is_match(line),
93 None => line.contains(&self.required),
94 }
95 }
96}
97
98impl Rule for WindowPatternRule {
99 fn id(&self) -> &str {
100 &self.id
101 }
102
103 fn severity(&self) -> Severity {
104 self.severity
105 }
106
107 fn file_glob(&self) -> Option<&str> {
108 self.glob.as_deref()
109 }
110
111 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
112 let mut violations = Vec::new();
113 let lines: Vec<&str> = ctx.content.lines().collect();
114 let total = lines.len();
115
116 for (idx, line) in lines.iter().enumerate() {
117 if !self.line_matches_trigger(line) {
118 continue;
119 }
120
121 let window_end = (idx + self.window_size + 1).min(total);
123 let window_start = idx.saturating_sub(self.window_size);
124
125 let found = (window_start..window_end)
126 .any(|i| i != idx && self.line_matches_required(lines[i]));
127
128 if !found {
129 violations.push(Violation {
130 rule_id: self.id.clone(),
131 severity: self.severity,
132 file: ctx.file_path.to_path_buf(),
133 line: Some(idx + 1),
134 column: Some(1),
135 message: self.message.clone(),
136 suggest: self.suggest.clone(),
137 source_line: Some(line.to_string()),
138 fix: None,
139 });
140 }
141 }
142
143 violations
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use std::path::Path;
151
152 fn make_config(
153 trigger: &str,
154 required: &str,
155 window: usize,
156 regex: bool,
157 ) -> RuleConfig {
158 RuleConfig {
159 id: "test-window".into(),
160 severity: Severity::Error,
161 message: "required pattern not found within window".into(),
162 suggest: Some("add the required pattern nearby".into()),
163 pattern: Some(trigger.to_string()),
164 condition_pattern: Some(required.to_string()),
165 max_count: Some(window),
166 regex,
167 ..Default::default()
168 }
169 }
170
171 fn check(rule: &WindowPatternRule, content: &str) -> Vec<Violation> {
172 let ctx = ScanContext {
173 file_path: Path::new("test.ts"),
174 content,
175 };
176 rule.check_file(&ctx)
177 }
178
179 #[test]
180 fn required_present_within_window() {
181 let config = make_config("DELETE FROM", "organizationId", 5, false);
182 let rule = WindowPatternRule::new(&config).unwrap();
183 let content = "DELETE FROM users\nWHERE organizationId = $1;";
184 let violations = check(&rule, content);
185 assert!(violations.is_empty());
186 }
187
188 #[test]
189 fn required_missing_within_window() {
190 let config = make_config("DELETE FROM", "organizationId", 2, false);
191 let rule = WindowPatternRule::new(&config).unwrap();
192 let content = "DELETE FROM users\nWHERE id = $1\nAND active = true;";
193 let violations = check(&rule, content);
194 assert_eq!(violations.len(), 1);
195 assert_eq!(violations[0].line, Some(1));
196 }
197
198 #[test]
199 fn required_outside_window() {
200 let config = make_config("DELETE FROM", "organizationId", 2, false);
201 let rule = WindowPatternRule::new(&config).unwrap();
202 let content = "DELETE FROM users\nWHERE id = $1\nAND active = true\nAND foo = bar\n-- organizationId check";
203 let violations = check(&rule, content);
204 assert_eq!(violations.len(), 1, "organizationId is outside 2-line window");
205 }
206
207 #[test]
208 fn regex_patterns() {
209 let config = make_config(r"(UPDATE|DELETE)\s+FROM", r"organization[Ii]d", 5, true);
210 let rule = WindowPatternRule::new(&config).unwrap();
211 let content = "UPDATE FROM users\nSET name = 'foo'\nWHERE organizationId = $1;";
212 let violations = check(&rule, content);
213 assert!(violations.is_empty());
214 }
215
216 #[test]
217 fn multiple_triggers() {
218 let config = make_config("DELETE FROM", "organizationId", 3, false);
219 let rule = WindowPatternRule::new(&config).unwrap();
220 let content = "DELETE FROM users WHERE organizationId = $1;\n\nDELETE FROM posts WHERE id = $1;";
221 let violations = check(&rule, content);
222 assert_eq!(violations.len(), 1, "second DELETE is missing organizationId");
223 }
224
225 #[test]
226 fn window_looks_before_trigger() {
227 let config = make_config("DELETE FROM", "organizationId", 3, false);
228 let rule = WindowPatternRule::new(&config).unwrap();
229 let content = "const orgId = organizationId;\n\nDELETE FROM users WHERE id = orgId;";
230 let violations = check(&rule, content);
231 assert!(violations.is_empty(), "organizationId appears before the trigger within window");
232 }
233
234 #[test]
235 fn missing_trigger_pattern_error() {
236 let config = RuleConfig {
237 id: "test".into(),
238 severity: Severity::Error,
239 message: "test".into(),
240 condition_pattern: Some("required".into()),
241 ..Default::default()
242 };
243 let err = WindowPatternRule::new(&config).unwrap_err();
244 assert!(matches!(err, RuleBuildError::MissingField(_, "pattern")));
245 }
246
247 #[test]
248 fn missing_condition_pattern_error() {
249 let config = RuleConfig {
250 id: "test".into(),
251 severity: Severity::Error,
252 message: "test".into(),
253 pattern: Some("trigger".into()),
254 ..Default::default()
255 };
256 let err = WindowPatternRule::new(&config).unwrap_err();
257 assert!(matches!(err, RuleBuildError::MissingField(_, "condition_pattern")));
258 }
259}