cuenv_ci/
affected.rs

1use crate::discovery::DiscoveredCIProject;
2use cuenv_core::manifest::Project;
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5
6#[must_use]
7#[allow(clippy::implicit_hasher)]
8pub fn compute_affected_tasks(
9    changed_files: &[PathBuf],
10    pipeline_tasks: &[String],
11    project_root: &Path,
12    config: &Project,
13    all_projects: &HashMap<String, DiscoveredCIProject>,
14) -> Vec<String> {
15    let mut affected = HashSet::new();
16    let mut directly_affected = HashSet::new();
17    let mut visited_external_cache: HashMap<String, bool> = HashMap::new();
18
19    // 1. Identify directly affected tasks (file changes in this project)
20    for task_name in pipeline_tasks {
21        if is_task_directly_affected(task_name, config, changed_files, project_root) {
22            directly_affected.insert(task_name.clone());
23            affected.insert(task_name.clone());
24        }
25    }
26
27    // 2. Transitive dependencies
28    // We need to check dependencies recursively including cross-project ones
29    let mut changed = true;
30    while changed {
31        changed = false;
32        for task_name in pipeline_tasks {
33            if affected.contains(task_name) {
34                continue;
35            }
36
37            if let Some(task_def) = config.tasks.get(task_name)
38                && let Some(task) = task_def.as_single()
39                && !task.depends_on.is_empty()
40            {
41                for dep in &task.depends_on {
42                    // Internal dependency
43                    if !dep.starts_with('#') {
44                        if affected.contains(dep) {
45                            affected.insert(task_name.clone());
46                            changed = true;
47                            break;
48                        }
49                        continue;
50                    }
51
52                    // External dependency (#project:task)
53                    if check_external_dependency(
54                        dep,
55                        all_projects,
56                        changed_files,
57                        &mut visited_external_cache,
58                    ) {
59                        affected.insert(task_name.clone());
60                        changed = true;
61                        break;
62                    }
63                }
64            }
65        }
66    }
67
68    // Return in pipeline order
69    pipeline_tasks
70        .iter()
71        .filter(|t| affected.contains(*t))
72        .cloned()
73        .collect()
74}
75
76#[must_use]
77pub fn matched_inputs_for_task(
78    task_name: &str,
79    config: &Project,
80    changed_files: &[PathBuf],
81    project_root: &Path,
82) -> Vec<String> {
83    let Some(task_def) = config.tasks.get(task_name) else {
84        return Vec::new();
85    };
86    let Some(task) = task_def.as_single() else {
87        return Vec::new();
88    };
89    task.iter_path_inputs()
90        .filter(|input_glob| matches_any(changed_files, project_root, input_glob))
91        .cloned()
92        .collect()
93}
94
95fn is_task_directly_affected(
96    task_name: &str,
97    config: &Project,
98    changed_files: &[PathBuf],
99    project_root: &Path,
100) -> bool {
101    if let Some(task_def) = config.tasks.get(task_name)
102        && let Some(task) = task_def.as_single()
103    {
104        task.iter_path_inputs()
105            .any(|input_glob| matches_any(changed_files, project_root, input_glob))
106    } else {
107        false
108    }
109}
110
111#[allow(clippy::implicit_hasher)]
112fn check_external_dependency(
113    dep: &str,
114    all_projects: &HashMap<String, DiscoveredCIProject>,
115    changed_files: &[PathBuf],
116    cache: &mut HashMap<String, bool>,
117) -> bool {
118    // dep format: "#project:task"
119    if let Some(result) = cache.get(dep) {
120        return *result;
121    }
122
123    // Break recursion cycle by assuming false initially (or handle cycles better?)
124    // For DAGs, temporary false is okay.
125    cache.insert(dep.to_string(), false);
126
127    let parts: Vec<&str> = dep[1..].split(':').collect();
128    if parts.len() < 2 {
129        return false;
130    }
131    let project_name = parts[0];
132    let task_name = parts[1];
133
134    let Some(project) = all_projects.get(project_name) else {
135        return false;
136    };
137
138    let project_root = project.path.parent().unwrap_or_else(|| Path::new("."));
139
140    // Check if directly affected
141    if is_task_directly_affected(task_name, &project.config, changed_files, project_root) {
142        cache.insert(dep.to_string(), true);
143        return true;
144    }
145
146    // Check transitive dependencies of the external task
147    if let Some(task_def) = project.config.tasks.get(task_name)
148        && let Some(task) = task_def.as_single()
149    {
150        for sub_dep in &task.depends_on {
151            if sub_dep.starts_with('#') {
152                // External ref
153                if check_external_dependency(sub_dep, all_projects, changed_files, cache) {
154                    cache.insert(dep.to_string(), true);
155                    return true;
156                }
157            } else {
158                // Internal ref within that project
159                // We need to resolve internal deps of the external project recursively.
160                // Construct implicit external ref: #project:sub_dep
161                let implicit_ref = format!("#{project_name}:{sub_dep}");
162                if check_external_dependency(&implicit_ref, all_projects, changed_files, cache) {
163                    cache.insert(dep.to_string(), true);
164                    return true;
165                }
166            }
167        }
168    }
169
170    false
171}
172
173fn matches_any(files: &[PathBuf], root: &Path, pattern: &str) -> bool {
174    // If pattern doesn't contain glob characters, treat it as a path prefix
175    // e.g., "crates" should match "crates/foo/bar.rs"
176    let is_simple_path = !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('[');
177
178    for file in files {
179        // Get relative path for matching:
180        // - If root is "." or empty, use file as-is
181        // - If file is already relative (doesn't start with root), use it as-is
182        //   (git returns relative paths, project_root may be absolute)
183        // - Otherwise strip the root prefix
184        let relative_path = if root == Path::new(".") || root.as_os_str().is_empty() {
185            file.as_path()
186        } else if file.is_relative() {
187            // File is already relative (e.g., from git diff), use as-is
188            file.as_path()
189        } else {
190            match file.strip_prefix(root) {
191                Ok(p) => p,
192                Err(_) => continue,
193            }
194        };
195
196        if is_simple_path {
197            // Check if the pattern is a prefix of the file path or exact match
198            let pattern_path = Path::new(pattern);
199            if relative_path.starts_with(pattern_path) || relative_path == pattern_path {
200                return true;
201            }
202        } else {
203            // Use glob matching for patterns with wildcards
204            let Ok(glob) = glob::Pattern::new(pattern) else {
205                continue;
206            };
207            if glob.matches_path(relative_path) {
208                return true;
209            }
210        }
211    }
212
213    false
214}