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 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 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 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 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 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 if let Some(result) = cache.get(dep) {
120 return *result;
121 }
122
123 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 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 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 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 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 let is_simple_path = !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('[');
177
178 for file in files {
179 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.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 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 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}