code_baseline/rules/
required_pattern.rs1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4
5#[derive(Debug)]
14pub struct RequiredPatternRule {
15 id: String,
16 severity: Severity,
17 message: String,
18 suggest: Option<String>,
19 glob: Option<String>,
20 pattern: String,
21 compiled_regex: Option<Regex>,
22 condition_pattern: Option<String>,
23 condition_regex: Option<Regex>,
24}
25
26impl RequiredPatternRule {
27 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
28 let pattern = config
29 .pattern
30 .as_ref()
31 .filter(|p| !p.is_empty())
32 .ok_or_else(|| RuleBuildError::MissingField(config.id.clone(), "pattern"))?
33 .clone();
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 let condition_regex = if config.regex {
44 config
45 .condition_pattern
46 .as_ref()
47 .map(|p| {
48 Regex::new(p)
49 .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))
50 })
51 .transpose()?
52 } else {
53 None
54 };
55
56 Ok(Self {
57 id: config.id.clone(),
58 severity: config.severity,
59 message: config.message.clone(),
60 suggest: config.suggest.clone(),
61 glob: config.glob.clone(),
62 pattern,
63 compiled_regex,
64 condition_pattern: config.condition_pattern.clone(),
65 condition_regex,
66 })
67 }
68
69 fn content_contains_pattern(&self, content: &str) -> bool {
70 if let Some(ref re) = self.compiled_regex {
71 re.is_match(content)
72 } else {
73 content.contains(&self.pattern)
74 }
75 }
76
77 fn content_matches_condition(&self, content: &str) -> bool {
78 match (&self.condition_pattern, &self.condition_regex) {
79 (Some(_), Some(re)) => re.is_match(content),
80 (Some(pat), None) => content.contains(pat.as_str()),
81 (None, _) => true, }
83 }
84}
85
86impl Rule for RequiredPatternRule {
87 fn id(&self) -> &str {
88 &self.id
89 }
90
91 fn severity(&self) -> Severity {
92 self.severity
93 }
94
95 fn file_glob(&self) -> Option<&str> {
96 self.glob.as_deref()
97 }
98
99 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
100 if !self.content_matches_condition(ctx.content) {
102 return Vec::new();
103 }
104
105 if self.content_contains_pattern(ctx.content) {
107 return Vec::new();
108 }
109
110 vec![Violation {
112 rule_id: self.id.clone(),
113 severity: self.severity,
114 file: ctx.file_path.to_path_buf(),
115 line: Some(1),
116 column: Some(1),
117 message: self.message.clone(),
118 suggest: self.suggest.clone(),
119 source_line: ctx.content.lines().next().map(|l| l.to_string()),
120 fix: None,
121 }]
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use std::path::Path;
129
130 fn make_config(pattern: &str, glob: Option<&str>) -> RuleConfig {
131 RuleConfig {
132 id: "test-required-pattern".into(),
133 severity: Severity::Error,
134 message: "required pattern missing".into(),
135 suggest: Some("add the required pattern".into()),
136 pattern: Some(pattern.to_string()),
137 glob: glob.map(|s| s.to_string()),
138 ..Default::default()
139 }
140 }
141
142 fn check(rule: &RequiredPatternRule, content: &str) -> Vec<Violation> {
143 let ctx = ScanContext {
144 file_path: Path::new("src/pages/Home.tsx"),
145 content,
146 };
147 rule.check_file(&ctx)
148 }
149
150 #[test]
151 fn pattern_present_no_violation() {
152 let config = make_config("ErrorBoundary", Some("**/*.tsx"));
153 let rule = RequiredPatternRule::new(&config).unwrap();
154 let violations = check(&rule, "import { ErrorBoundary } from 'react-error-boundary';");
155 assert!(violations.is_empty());
156 }
157
158 #[test]
159 fn pattern_missing_one_violation() {
160 let config = make_config("ErrorBoundary", Some("**/*.tsx"));
161 let rule = RequiredPatternRule::new(&config).unwrap();
162 let violations = check(&rule, "export default function Home() { return <div/>; }");
163 assert_eq!(violations.len(), 1);
164 assert_eq!(violations[0].rule_id, "test-required-pattern");
165 }
166
167 #[test]
168 fn regex_pattern_present() {
169 let mut config = make_config(r"export\s+default", Some("**/*.tsx"));
170 config.regex = true;
171 let rule = RequiredPatternRule::new(&config).unwrap();
172 let violations = check(&rule, "export default function App() {}");
173 assert!(violations.is_empty());
174 }
175
176 #[test]
177 fn regex_pattern_missing() {
178 let mut config = make_config(r"export\s+default", Some("**/*.tsx"));
179 config.regex = true;
180 let rule = RequiredPatternRule::new(&config).unwrap();
181 let violations = check(&rule, "const App = () => {};");
182 assert_eq!(violations.len(), 1);
183 }
184
185 #[test]
186 fn condition_pattern_met_required_missing() {
187 let mut config = make_config("validateInput", Some("**/*.ts"));
188 config.condition_pattern = Some("app.post(".to_string());
189 let rule = RequiredPatternRule::new(&config).unwrap();
190 let violations = check(&rule, "app.post('/api/users', handler);");
191 assert_eq!(violations.len(), 1);
192 }
193
194 #[test]
195 fn condition_pattern_met_required_present() {
196 let mut config = make_config("validateInput", Some("**/*.ts"));
197 config.condition_pattern = Some("app.post(".to_string());
198 let rule = RequiredPatternRule::new(&config).unwrap();
199 let violations = check(&rule, "app.post('/api', validateInput(schema), handler);");
200 assert!(violations.is_empty());
201 }
202
203 #[test]
204 fn condition_pattern_not_met_skips() {
205 let mut config = make_config("validateInput", Some("**/*.ts"));
206 config.condition_pattern = Some("app.post(".to_string());
207 let rule = RequiredPatternRule::new(&config).unwrap();
208 let violations = check(&rule, "app.get('/api/health', handler);");
209 assert!(violations.is_empty());
210 }
211
212 #[test]
213 fn missing_pattern_error() {
214 let config = RuleConfig {
215 id: "test".into(),
216 severity: Severity::Error,
217 message: "test".into(),
218 ..Default::default()
219 };
220 let err = RequiredPatternRule::new(&config).unwrap_err();
221 assert!(matches!(err, RuleBuildError::MissingField(_, "pattern")));
222 }
223}