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        if is_simple_path {
40            // Check if the pattern is a prefix of the file path
41            let pattern_path = Path::new(pattern);
42            if relative_path.starts_with(pattern_path) {
43                return true;
44            }
45        } else {
46            // Use glob matching for patterns with wildcards
47            let Ok(glob) = Glob::new(pattern) else {
48                tracing::trace!(pattern, "Skipping invalid glob pattern");
49                continue;
50            };
51            let Ok(set) = GlobSetBuilder::new().add(glob).build() else {
52                continue;
53            };
54            if set.is_match(&relative_path) {
55                return true;
56            }
57        }
58    }
59
60    false
61}
62
63/// Build a GlobSet from multiple patterns for efficient batch matching.
64///
65/// # Arguments
66///
67/// * `patterns` - Iterator of glob patterns
68///
69/// # Returns
70///
71/// A compiled GlobSet, or None if no valid patterns
72pub fn build_glob_set<'a>(patterns: impl Iterator<Item = &'a str>) -> Option<GlobSet> {
73    let mut builder = GlobSetBuilder::new();
74    let mut has_patterns = false;
75
76    for pattern in patterns {
77        if let Ok(glob) = Glob::new(pattern) {
78            builder.add(glob);
79            has_patterns = true;
80        } else {
81            tracing::trace!(pattern, "Skipping invalid glob pattern");
82        }
83    }
84
85    if !has_patterns {
86        return None;
87    }
88
89    builder.build().ok()
90}
91
92/// Normalize a file path relative to a project root.
93///
94/// Handles the common case where git diff returns repo-relative paths,
95/// but we need to match against project-relative patterns.
96fn normalize_path(file: &Path, project_root: &Path) -> PathBuf {
97    // If root is "." or empty, use file as-is
98    if project_root == Path::new(".") || project_root.as_os_str().is_empty() {
99        return file.to_path_buf();
100    }
101
102    // If file is already relative (e.g., from git diff), check if it starts with project root
103    if file.is_relative() {
104        // Try to strip the project root prefix from the file
105        if let Ok(stripped) = file.strip_prefix(project_root) {
106            return stripped.to_path_buf();
107        }
108        // File is relative but doesn't start with project root - use as-is
109        return file.to_path_buf();
110    }
111
112    // File is absolute - strip the project root prefix
113    file.strip_prefix(project_root)
114        .map(|p| p.to_path_buf())
115        .unwrap_or_else(|_| file.to_path_buf())
116}
117
118/// Trait for types that can determine if they are affected by file changes.
119///
120/// Implement this trait on task types to enable affected detection in
121/// CI pipelines and incremental builds.
122pub trait AffectedBy {
123    /// Returns true if this item is affected by the given file changes.
124    ///
125    /// # Arguments
126    ///
127    /// * `changed_files` - Paths of files that have changed (typically repo-relative)
128    /// * `project_root` - Root path of the project containing this item
129    ///
130    /// # Returns
131    ///
132    /// `true` if the item should be considered affected and needs to run
133    fn is_affected_by(&self, changed_files: &[PathBuf], project_root: &Path) -> bool;
134
135    /// Returns the input patterns that determine what affects this item.
136    ///
137    /// Used for debugging and reporting which patterns matched.
138    fn input_patterns(&self) -> Vec<&str>;
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_matches_pattern_simple_prefix() {
147        let files = vec![PathBuf::from("crates/foo/bar.rs")];
148        let root = Path::new(".");
149
150        assert!(matches_pattern(&files, root, "crates"));
151        assert!(matches_pattern(&files, root, "crates/foo"));
152        assert!(matches_pattern(&files, root, "crates/foo/bar.rs"));
153    }
154
155    #[test]
156    fn test_matches_pattern_no_match() {
157        let files = vec![PathBuf::from("src/lib.rs")];
158        let root = Path::new(".");
159
160        assert!(!matches_pattern(&files, root, "crates"));
161        assert!(!matches_pattern(&files, root, "tests"));
162    }
163
164    #[test]
165    fn test_matches_pattern_glob() {
166        let files = vec![PathBuf::from("src/lib.rs"), PathBuf::from("src/main.rs")];
167        let root = Path::new(".");
168
169        assert!(matches_pattern(&files, root, "src/*.rs"));
170        assert!(!matches_pattern(&files, root, "*.txt"));
171    }
172
173    #[test]
174    fn test_matches_pattern_with_project_root() {
175        // File is repo-relative, project root is a subdirectory
176        let files = vec![PathBuf::from("projects/website/src/app.rs")];
177        let root = Path::new("projects/website");
178
179        // Pattern is project-relative
180        assert!(matches_pattern(&files, root, "src"));
181        assert!(matches_pattern(&files, root, "src/app.rs"));
182    }
183
184    #[test]
185    fn test_matches_pattern_different_project() {
186        // File is in a different project
187        let files = vec![PathBuf::from("projects/api/src/main.rs")];
188        let root = Path::new("projects/website");
189
190        // Should not match - file is in api, not website
191        assert!(!matches_pattern(&files, root, "src"));
192    }
193
194    #[test]
195    fn test_normalize_path_relative_file_with_project_root() {
196        let file = Path::new("projects/website/src/lib.rs");
197        let root = Path::new("projects/website");
198
199        let normalized = normalize_path(file, root);
200        assert_eq!(normalized, PathBuf::from("src/lib.rs"));
201    }
202
203    #[test]
204    fn test_normalize_path_dot_root() {
205        let file = Path::new("src/lib.rs");
206        let root = Path::new(".");
207
208        let normalized = normalize_path(file, root);
209        assert_eq!(normalized, PathBuf::from("src/lib.rs"));
210    }
211
212    #[test]
213    fn test_build_glob_set() {
214        let patterns = ["src/**/*.rs", "tests/*.rs"];
215        let set = build_glob_set(patterns.iter().copied()).unwrap();
216
217        assert!(set.is_match("src/lib.rs"));
218        assert!(set.is_match("src/foo/bar.rs"));
219        assert!(set.is_match("tests/test.rs"));
220        assert!(!set.is_match("docs/readme.md"));
221    }
222
223    #[test]
224    fn test_build_glob_set_invalid_patterns() {
225        let patterns = ["[invalid", "src/**"];
226        let set = build_glob_set(patterns.iter().copied()).unwrap();
227
228        // Invalid pattern is skipped, valid one still works
229        assert!(set.is_match("src/lib.rs"));
230    }
231
232    #[test]
233    fn test_build_glob_set_empty() {
234        let patterns: [&str; 0] = [];
235        let set = build_glob_set(patterns.iter().copied());
236        assert!(set.is_none());
237    }
238}