Skip to main content

cuenv_core/
affected.rs

1//! Affected detection utilities for tasks and files.
2//!
3//! This module provides utilities for determining if tasks are affected by file changes,
4//! used by CI pipelines and incremental builds.
5//!
6//! # Design Philosophy
7//!
8//! - Tasks with inputs are affected if any input pattern matches changed files
9//! - Tasks with NO inputs are always considered affected (we can't determine what affects them)
10//! - This is the safe behavior - if we can't determine, we run the task
11
12use globset::{Glob, GlobSet, GlobSetBuilder};
13use std::path::{Path, PathBuf};
14
15/// Check if any of the given files match a pattern.
16///
17/// Supports two matching modes:
18/// - **Simple paths**: Patterns without wildcards (`*`, `?`, `[`) are treated as path prefixes.
19///   For example, `"crates"` matches `"crates/foo/bar.rs"`.
20/// - **Glob patterns**: Patterns with wildcards use glob matching.
21///
22/// # Arguments
23///
24/// * `files` - Changed file paths (typically repo-relative from git diff)
25/// * `project_root` - The project root for normalizing paths
26/// * `pattern` - The input pattern to match against
27///
28/// # Returns
29///
30/// `true` if any file matches the pattern
31pub fn matches_pattern(files: &[PathBuf], project_root: &Path, pattern: &str) -> bool {
32    // If pattern doesn't contain glob characters, treat it as a path prefix
33    let is_simple_path = !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('[');
34
35    for file in files {
36        // Normalize the file path relative to project root
37        let relative_path = normalize_path(file, project_root);
38
39        let matched = if is_simple_path {
40            // Check if the pattern is a prefix of the file path
41            let pattern_path = Path::new(pattern);
42            relative_path.starts_with(pattern_path)
43        } else {
44            // Use glob matching for patterns with wildcards
45            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
72/// Build a GlobSet from multiple patterns for efficient batch matching.
73///
74/// # Arguments
75///
76/// * `patterns` - Iterator of glob patterns
77///
78/// # Returns
79///
80/// A compiled GlobSet, or None if no valid patterns
81pub 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
101/// Normalize a file path relative to a project root.
102///
103/// Handles the common case where git diff returns repo-relative paths,
104/// but we need to match against project-relative patterns.
105fn normalize_path(file: &Path, project_root: &Path) -> PathBuf {
106    // If root is "." or empty, use file as-is
107    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
172/// Trait for types that can determine if they are affected by file changes.
173///
174/// Implement this trait on task types to enable affected detection in
175/// CI pipelines and incremental builds.
176pub trait AffectedBy {
177    /// Returns true if this item is affected by the given file changes.
178    ///
179    /// # Arguments
180    ///
181    /// * `changed_files` - Paths of files that have changed (typically repo-relative)
182    /// * `project_root` - Root path of the project containing this item
183    ///
184    /// # Returns
185    ///
186    /// `true` if the item should be considered affected and needs to run
187    fn is_affected_by(&self, changed_files: &[PathBuf], project_root: &Path) -> bool;
188
189    /// Returns the input patterns that determine what affects this item.
190    ///
191    /// Used for debugging and reporting which patterns matched.
192    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        // File is repo-relative, project root is a subdirectory
230        let files = vec![PathBuf::from("projects/website/src/app.rs")];
231        let root = Path::new("projects/website");
232
233        // Pattern is project-relative
234        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        // File is in a different project
241        let files = vec![PathBuf::from("projects/api/src/main.rs")];
242        let root = Path::new("projects/website");
243
244        // Should not match - file is in api, not website
245        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        // Invalid pattern is skipped, valid one still works
311        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}