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