Skip to main content

code_baseline/rules/
ratchet.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4
5/// A ratchet rule that counts literal pattern occurrences across all files.
6///
7/// Each match is reported as a violation. The scan layer post-processes:
8/// if total matches <= `max_count`, all violations are suppressed (the team
9/// is under budget). If over `max_count`, all violations are kept.
10#[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                // Regex mode
83                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                // Literal mode
98                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)); // 1-indexed
196    }
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}