1use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use alba_core::git::changed_files;
11use alba_core::{Beam, BeamId, Project, SourceMap, execution_subgraph};
12
13use crate::EngineError;
14use crate::scheduler::Targets;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Selection {
19 Beam(BeamId),
21 Affected {
24 reference: String,
25 within: Option<BeamId>,
26 },
27}
28
29impl Selection {
30 pub fn watched_target(&self) -> Option<&BeamId> {
33 match self {
34 Selection::Beam(id) => Some(id),
35 Selection::Affected { within, .. } => within.as_ref(),
36 }
37 }
38}
39
40pub fn affected(
45 project: &Project,
46 sources: &SourceMap,
47 root: &Path,
48 reference: &str,
49) -> Result<Vec<BeamId>, EngineError> {
50 let changed: Vec<PathBuf> = changed_files(root, reference)?
51 .into_iter()
52 .map(|path| root.join(path))
53 .collect();
54 Ok(affected_beams(project, sources, &changed))
55}
56
57pub fn select(
61 project: &Project,
62 sources: &SourceMap,
63 root: &Path,
64 selection: &Selection,
65) -> Result<Targets, EngineError> {
66 match selection {
67 Selection::Beam(id) => {
68 execution_subgraph(project, id)?;
69 Ok(Targets::beam(id.clone()))
70 }
71 Selection::Affected { reference, within } => {
72 let affected = affected(project, sources, root, reference)?;
73 let beams = match within {
74 Some(id) => {
78 execution_subgraph(project, id)?;
79 if affected.contains(id) {
80 vec![id.clone()]
81 } else {
82 Vec::new()
83 }
84 }
85 None => affected
88 .into_iter()
89 .filter(|id| {
90 project
91 .beams
92 .iter()
93 .any(|beam| beam.id == *id && beam.params.is_empty())
94 })
95 .collect(),
96 };
97 Ok(Targets {
98 beams,
99 affected_by: Some(reference.clone()),
100 })
101 }
102 }
103}
104
105pub(crate) fn affected_beams(
107 project: &Project,
108 sources: &SourceMap,
109 changed: &[PathBuf],
110) -> Vec<BeamId> {
111 let changed_set: HashSet<&PathBuf> = changed.iter().collect();
119 let mut affected: HashSet<&str> = HashSet::new();
120
121 for beam in &project.beams {
122 let beamfile = sources
123 .get(beam.source)
124 .map(|(path, _)| std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()));
125 if beamfile.is_some_and(|beamfile| changed_set.contains(&beamfile)) {
126 affected.insert(beam.id.0.as_str());
127 }
128 }
129
130 let mut groups: HashMap<&Path, Vec<&Beam>> = HashMap::new();
137 for beam in &project.beams {
138 if !beam.inputs.is_empty() {
139 groups.entry(beam.dir.as_path()).or_default().push(beam);
140 }
141 }
142 for (base, beams) in groups {
143 let mut builder = globset::GlobSetBuilder::new();
144 let mut owners: Vec<&str> = Vec::new();
145 for beam in &beams {
146 for pattern in &beam.inputs {
147 if let Ok(glob) = globset::Glob::new(pattern) {
148 builder.add(glob);
149 owners.push(beam.id.0.as_str());
150 }
151 }
152 }
153 let Ok(set) = builder.build() else { continue };
154 for path in changed {
155 let Ok(relative) = path.strip_prefix(base) else {
156 continue;
157 };
158 let unified = relative.to_string_lossy().replace('\\', "/");
159 let candidate = globset::Candidate::new(&unified);
160 for index in set.matches_candidate(&candidate) {
161 affected.insert(owners[index]);
162 }
163 }
164 }
165
166 loop {
170 let before = affected.len();
171 for beam in &project.beams {
172 if beam
173 .needs
174 .iter()
175 .any(|need| affected.contains(need.value.0.as_str()))
176 {
177 affected.insert(beam.id.0.as_str());
178 }
179 }
180 if affected.len() == before {
181 break;
182 }
183 }
184
185 project
186 .beams
187 .iter()
188 .filter(|beam| affected.contains(beam.id.0.as_str()))
189 .map(|beam| beam.id.clone())
190 .collect()
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use std::time::{Duration, Instant};
197
198 #[test]
202 fn affected_selection_stays_imperceptible() {
203 let mut source = String::new();
204 for i in 0..300 {
205 let needs = if i == 0 {
206 String::new()
207 } else {
208 format!("needs [b{}] ", i - 1)
209 };
210 source.push_str(&format!(
211 "beam b{i} {{ {needs}inputs [\"src/m{i}/**\"] run \"x\" }}\n"
212 ));
213 }
214 let dir = tempfile::tempdir().unwrap();
215 std::fs::write(dir.path().join("Beamfile"), &source).unwrap();
216 let (project, sources) = alba_core::load_project(&dir.path().join("Beamfile")).unwrap();
217 let changed: Vec<PathBuf> = (0..2000)
218 .map(|i| dir.path().join(format!("src/m{}/f{i}.rs", i % 300)))
219 .collect();
220
221 let mut samples: Vec<Duration> = (0..15)
222 .map(|_| {
223 let start = Instant::now();
224 let affected = affected_beams(&project, &sources, &changed);
225 assert_eq!(affected.len(), 300);
226 start.elapsed()
227 })
228 .collect();
229 samples.sort();
230 let median = samples[samples.len() / 2];
231 assert!(median < Duration::from_millis(50), "median {median:?}");
232 }
233}