Skip to main content

code_baseline/rules/
window_pattern.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4
5/// Enforces that when a trigger pattern appears, a required pattern
6/// must also appear within a configurable window of lines.
7///
8/// Example: "every UPDATE/DELETE query must have an organizationId
9/// within 80 lines" or "every async route handler must have try/catch
10/// within 10 lines".
11///
12/// Config fields:
13/// - `pattern` — trigger pattern (literal or regex)
14/// - `condition_pattern` — required pattern that must appear nearby
15/// - `max_count` — window size (number of lines to search after trigger)
16/// - `regex` — whether patterns are regex
17#[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            // Search within window for the required pattern
122            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}