Skip to main content

code_baseline/rules/
required_pattern.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4
5/// Ensures that files matching a glob contain a required pattern.
6///
7/// If a file matches the glob but does NOT contain the pattern, a violation
8/// is emitted. Useful for enforcing conventions like "all page components
9/// must have an ErrorBoundary" or "all API routes must validate input".
10///
11/// Optionally supports a `condition_pattern`: the required pattern is only
12/// enforced if the condition pattern is present in the file.
13#[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, // no condition = always enforce
82        }
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 there's a condition pattern and the file doesn't match it, skip
101        if !self.content_matches_condition(ctx.content) {
102            return Vec::new();
103        }
104
105        // If the file contains the required pattern, it's fine
106        if self.content_contains_pattern(ctx.content) {
107            return Vec::new();
108        }
109
110        // File matches glob but is missing the required pattern
111        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}