1use globset::{Glob, GlobSet, GlobSetBuilder};
13use std::path::{Path, PathBuf};
14
15pub fn matches_pattern(files: &[PathBuf], project_root: &Path, pattern: &str) -> bool {
32 let is_simple_path = !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('[');
34
35 for file in files {
36 let relative_path = normalize_path(file, project_root);
38
39 let matched = if is_simple_path {
40 let pattern_path = Path::new(pattern);
42 relative_path.starts_with(pattern_path)
43 } else {
44 let Ok(glob) = Glob::new(pattern) else {
46 tracing::trace!(pattern, "Skipping invalid glob pattern");
47 continue;
48 };
49 let Ok(set) = GlobSetBuilder::new().add(glob).build() else {
50 continue;
51 };
52 set.is_match(&relative_path)
53 };
54
55 tracing::trace!(
56 file = %file.display(),
57 normalized = %relative_path.display(),
58 project_root = %project_root.display(),
59 pattern,
60 matched,
61 "Compared changed file against affected pattern"
62 );
63
64 if matched {
65 return true;
66 }
67 }
68
69 false
70}
71
72pub fn build_glob_set<'a>(patterns: impl Iterator<Item = &'a str>) -> Option<GlobSet> {
82 let mut builder = GlobSetBuilder::new();
83 let mut has_patterns = false;
84
85 for pattern in patterns {
86 if let Ok(glob) = Glob::new(pattern) {
87 builder.add(glob);
88 has_patterns = true;
89 } else {
90 tracing::trace!(pattern, "Skipping invalid glob pattern");
91 }
92 }
93
94 if !has_patterns {
95 return None;
96 }
97
98 builder.build().ok()
99}
100
101fn normalize_path(file: &Path, project_root: &Path) -> PathBuf {
106 if project_root == Path::new(".") || project_root.as_os_str().is_empty() {
108 tracing::trace!(
109 file = %file.display(),
110 project_root = %project_root.display(),
111 normalized = %file.display(),
112 "Project root is current directory; using file path as-is"
113 );
114 return file.to_path_buf();
115 }
116
117 if let Some(stripped) = strip_project_root_prefix(file, project_root) {
118 tracing::trace!(
119 file = %file.display(),
120 project_root = %project_root.display(),
121 normalized = %stripped.display(),
122 "Normalized changed file relative to project root"
123 );
124 return stripped;
125 }
126
127 tracing::trace!(
128 file = %file.display(),
129 project_root = %project_root.display(),
130 normalized = %file.display(),
131 "Could not normalize changed file against project root; using original path"
132 );
133 file.to_path_buf()
134}
135
136fn strip_project_root_prefix(file: &Path, project_root: &Path) -> Option<PathBuf> {
137 if let Ok(stripped) = file.strip_prefix(project_root) {
138 return Some(stripped.to_path_buf());
139 }
140
141 if file.is_relative() && project_root.is_absolute() {
142 return strip_relative_file_with_absolute_project_root(file, project_root);
143 }
144
145 None
146}
147
148fn strip_relative_file_with_absolute_project_root(
149 file: &Path,
150 project_root: &Path,
151) -> Option<PathBuf> {
152 let root_components: Vec<_> = project_root.components().collect();
153
154 for suffix_start in 0..root_components.len() {
155 let candidate: PathBuf = root_components[suffix_start..]
156 .iter()
157 .map(|component| component.as_os_str())
158 .collect();
159
160 if candidate.as_os_str().is_empty() || candidate.is_absolute() {
161 continue;
162 }
163
164 if file.starts_with(&candidate) {
165 return file.strip_prefix(&candidate).ok().map(Path::to_path_buf);
166 }
167 }
168
169 None
170}
171
172pub trait AffectedBy {
177 fn is_affected_by(&self, changed_files: &[PathBuf], project_root: &Path) -> bool;
188
189 fn input_patterns(&self) -> Vec<&str>;
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn test_matches_pattern_simple_prefix() {
201 let files = vec![PathBuf::from("crates/foo/bar.rs")];
202 let root = Path::new(".");
203
204 assert!(matches_pattern(&files, root, "crates"));
205 assert!(matches_pattern(&files, root, "crates/foo"));
206 assert!(matches_pattern(&files, root, "crates/foo/bar.rs"));
207 }
208
209 #[test]
210 fn test_matches_pattern_no_match() {
211 let files = vec![PathBuf::from("src/lib.rs")];
212 let root = Path::new(".");
213
214 assert!(!matches_pattern(&files, root, "crates"));
215 assert!(!matches_pattern(&files, root, "tests"));
216 }
217
218 #[test]
219 fn test_matches_pattern_glob() {
220 let files = vec![PathBuf::from("src/lib.rs"), PathBuf::from("src/main.rs")];
221 let root = Path::new(".");
222
223 assert!(matches_pattern(&files, root, "src/*.rs"));
224 assert!(!matches_pattern(&files, root, "*.txt"));
225 }
226
227 #[test]
228 fn test_matches_pattern_with_project_root() {
229 let files = vec![PathBuf::from("projects/website/src/app.rs")];
231 let root = Path::new("projects/website");
232
233 assert!(matches_pattern(&files, root, "src"));
235 assert!(matches_pattern(&files, root, "src/app.rs"));
236 }
237
238 #[test]
239 fn test_matches_pattern_different_project() {
240 let files = vec![PathBuf::from("projects/api/src/main.rs")];
242 let root = Path::new("projects/website");
243
244 assert!(!matches_pattern(&files, root, "src"));
246 }
247
248 #[test]
249 fn test_normalize_path_relative_file_with_project_root() {
250 let file = Path::new("projects/website/src/lib.rs");
251 let root = Path::new("projects/website");
252
253 let normalized = normalize_path(file, root);
254 assert_eq!(normalized, PathBuf::from("src/lib.rs"));
255 }
256
257 #[test]
258 fn test_normalize_path_dot_root() {
259 let file = Path::new("src/lib.rs");
260 let root = Path::new(".");
261
262 let normalized = normalize_path(file, root);
263 assert_eq!(normalized, PathBuf::from("src/lib.rs"));
264 }
265
266 #[test]
267 fn test_normalize_path_relative_file_with_absolute_project_root() {
268 let file = Path::new("chat/src/lib.rs");
269 let root = Path::new("/repo/chat");
270
271 let normalized = normalize_path(file, root);
272 assert_eq!(normalized, PathBuf::from("src/lib.rs"));
273 }
274
275 #[test]
276 fn test_normalize_path_relative_file_with_nested_absolute_project_root() {
277 let file = Path::new("infrastructure/waddle.cloud/src/main.rs");
278 let root = Path::new("/repo/infrastructure/waddle.cloud");
279
280 let normalized = normalize_path(file, root);
281 assert_eq!(normalized, PathBuf::from("src/main.rs"));
282 }
283
284 #[test]
285 fn test_matches_pattern_with_absolute_project_root() {
286 let files = vec![PathBuf::from("chat/src/app.rs")];
287 let root = Path::new("/repo/chat");
288
289 assert!(matches_pattern(&files, root, "src"));
290 assert!(matches_pattern(&files, root, "src/app.rs"));
291 assert!(matches_pattern(&files, root, "src/**"));
292 }
293
294 #[test]
295 fn test_build_glob_set() {
296 let patterns = ["src/**/*.rs", "tests/*.rs"];
297 let set = build_glob_set(patterns.iter().copied()).unwrap();
298
299 assert!(set.is_match("src/lib.rs"));
300 assert!(set.is_match("src/foo/bar.rs"));
301 assert!(set.is_match("tests/test.rs"));
302 assert!(!set.is_match("docs/readme.md"));
303 }
304
305 #[test]
306 fn test_build_glob_set_invalid_patterns() {
307 let patterns = ["[invalid", "src/**"];
308 let set = build_glob_set(patterns.iter().copied()).unwrap();
309
310 assert!(set.is_match("src/lib.rs"));
312 }
313
314 #[test]
315 fn test_build_glob_set_empty() {
316 let patterns: [&str; 0] = [];
317 let set = build_glob_set(patterns.iter().copied());
318 assert!(set.is_none());
319 }
320}