1use globset::{Glob, GlobSet, GlobSetBuilder};
26use serde::Deserialize;
27use thiserror::Error;
28
29use crate::location::FilePath;
30
31#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
33#[serde(rename_all = "camelCase", default)]
34pub struct Gates {
35 pub path_matches: Vec<String>,
37 pub path_not_matches: Vec<String>,
39 pub file_contains: Vec<String>,
41 pub file_not_contains: Vec<String>,
43}
44
45impl Gates {
46 #[must_use]
48 pub fn is_empty(&self) -> bool {
49 self.path_matches.is_empty()
50 && self.path_not_matches.is_empty()
51 && self.file_contains.is_empty()
52 && self.file_not_contains.is_empty()
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Error)]
58#[error("invalid `{field}` gate pattern `{pattern}`: {detail}")]
59pub struct GateError {
60 pub field: &'static str,
62 pattern: String,
64 detail: String,
66}
67
68#[derive(Debug)]
70pub struct CompiledGates {
71 path_matches: Option<GlobSet>,
72 path_not_matches: Option<GlobSet>,
73 file_contains: Vec<String>,
74 file_not_contains: Vec<String>,
75}
76
77impl CompiledGates {
78 pub fn compile(gates: &Gates) -> Result<Self, GateError> {
84 Ok(Self {
85 path_matches: compile_set(&gates.path_matches, "pathMatches")?,
86 path_not_matches: compile_set(&gates.path_not_matches, "pathNotMatches")?,
87 file_contains: gates.file_contains.clone(),
88 file_not_contains: gates.file_not_contains.clone(),
89 })
90 }
91
92 #[must_use]
96 pub fn admits_path(&self, path: &FilePath) -> bool {
97 let path = path.as_str();
98 if self
99 .path_not_matches
100 .as_ref()
101 .is_some_and(|set| set.is_match(path))
102 {
103 return false;
104 }
105 self.path_matches
106 .as_ref()
107 .is_none_or(|set| set.is_match(path))
108 }
109
110 #[must_use]
116 pub fn admits_content(&self, bytes: &[u8]) -> bool {
117 for needle in &self.file_not_contains {
118 if contains(bytes, needle.as_bytes()) {
119 return false;
120 }
121 }
122 self.file_contains
125 .iter()
126 .all(|needle| contains(bytes, needle.as_bytes()))
127 }
128
129 #[must_use]
131 pub fn has_content_gates(&self) -> bool {
132 !self.file_contains.is_empty() || !self.file_not_contains.is_empty()
133 }
134}
135
136fn contains(haystack: &[u8], needle: &[u8]) -> bool {
137 if needle.is_empty() {
138 return true;
139 }
140 memchr::memmem::find(haystack, needle).is_some()
141}
142
143fn compile_set(patterns: &[String], field: &'static str) -> Result<Option<GlobSet>, GateError> {
144 if patterns.is_empty() {
145 return Ok(None);
146 }
147 let mut builder = GlobSetBuilder::new();
148 for pattern in patterns {
149 let glob = Glob::new(pattern).map_err(|e| GateError {
150 field,
151 pattern: pattern.clone(),
152 detail: e.to_string(),
153 })?;
154 builder.add(glob);
155 }
156 builder.build().map(Some).map_err(|e| GateError {
157 field,
158 pattern: patterns.join(", "),
159 detail: e.to_string(),
160 })
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn gates(
168 path_matches: &[&str],
169 path_not_matches: &[&str],
170 file_contains: &[&str],
171 file_not_contains: &[&str],
172 ) -> CompiledGates {
173 let own = |v: &[&str]| v.iter().map(|s| (*s).to_owned()).collect();
174 CompiledGates::compile(&Gates {
175 path_matches: own(path_matches),
176 path_not_matches: own(path_not_matches),
177 file_contains: own(file_contains),
178 file_not_contains: own(file_not_contains),
179 })
180 .expect("compiles")
181 }
182
183 #[test]
184 fn no_gates_admits_everything() {
185 let g = gates(&[], &[], &[], &[]);
186 assert!(g.admits_path(&FilePath::new("anything.ts")));
187 assert!(g.admits_content(b""));
188 assert!(g.admits_content(b"whatever"));
189 assert!(!g.has_content_gates());
190 }
191
192 #[test]
193 fn path_matches_narrows() {
194 let g = gates(&["src/**/*.tsx"], &[], &[], &[]);
195 assert!(g.admits_path(&FilePath::new("src/a/B.tsx")));
196 assert!(!g.admits_path(&FilePath::new("src/a.ts")));
197 assert!(!g.admits_path(&FilePath::new("lib/a.tsx")));
198 }
199
200 #[test]
201 fn path_not_matches_wins_over_path_matches() {
202 let g = gates(&["src/**"], &["**/generated/**"], &[], &[]);
203 assert!(g.admits_path(&FilePath::new("src/a.ts")));
204 assert!(!g.admits_path(&FilePath::new("src/generated/a.ts")));
205 }
206
207 #[test]
208 fn file_contains_requires_all_of_them() {
209 let g = gates(&[], &[], &["makeStyles", "theme"], &[]);
212 assert!(g.admits_content(b"import { makeStyles } from 'x'; theme.spacing"));
213 assert!(!g.admits_content(b"import { makeStyles } from 'x'"));
214 assert!(!g.admits_content(b"theme.spacing"));
215 }
216
217 #[test]
218 fn file_not_contains_rejects_on_any_of_them() {
219 let g = gates(&[], &[], &[], &["@generated", "DO NOT EDIT"]);
220 assert!(g.admits_content(b"ordinary source"));
221 assert!(!g.admits_content(b"// @generated by something"));
222 assert!(!g.admits_content(b"// DO NOT EDIT"));
223 }
224
225 #[test]
226 fn content_gates_are_reported_so_a_caller_can_skip_the_read() {
227 assert!(!gates(&["**/*.ts"], &[], &[], &[]).has_content_gates());
228 assert!(gates(&[], &[], &["x"], &[]).has_content_gates());
229 assert!(gates(&[], &[], &[], &["x"]).has_content_gates());
230 }
231
232 #[test]
233 fn gates_only_ever_narrow() {
234 let ungated = gates(&[], &[], &[], &[]);
240 let gated = gates(&["src/**"], &["**/gen/**"], &["needle"], &["skip"]);
241
242 for path in ["src/a.ts", "src/gen/a.ts", "lib/a.ts"] {
243 let path = FilePath::new(path);
244 if gated.admits_path(&path) {
245 assert!(
246 ungated.admits_path(&path),
247 "gating admitted more for {path}"
248 );
249 }
250 }
251 for bytes in [b"needle".as_slice(), b"skip needle", b"nothing"] {
252 if gated.admits_content(bytes) {
253 assert!(
254 ungated.admits_content(bytes),
255 "gating admitted more content"
256 );
257 }
258 }
259 }
260
261 #[test]
262 fn an_empty_needle_matches_anything() {
263 let g = gates(&[], &[], &[""], &[]);
265 assert!(g.admits_content(b""));
266 assert!(g.admits_content(b"anything"));
267 }
268
269 #[test]
270 fn content_matching_is_byte_exact() {
271 let g = gates(&[], &[], &["makeStyles"], &[]);
272 assert!(
273 !g.admits_content(b"makestyles"),
274 "matching must be case-sensitive"
275 );
276 assert!(g.admits_content("prefix makeStyles suffix".as_bytes()));
277 }
278
279 #[test]
280 fn a_malformed_pattern_names_its_field() {
281 let err = CompiledGates::compile(&Gates {
282 path_matches: vec!["src/[".to_owned()],
283 ..Gates::default()
284 })
285 .expect_err("malformed");
286 assert_eq!(err.field, "pathMatches");
287
288 let err = CompiledGates::compile(&Gates {
289 path_not_matches: vec!["src/[".to_owned()],
290 ..Gates::default()
291 })
292 .expect_err("malformed");
293 assert_eq!(err.field, "pathNotMatches");
294 }
295
296 #[test]
297 fn gates_report_whether_they_are_empty() {
298 assert!(Gates::default().is_empty());
299 assert!(
300 !Gates {
301 file_contains: vec!["x".to_owned()],
302 ..Gates::default()
303 }
304 .is_empty()
305 );
306 }
307}