1use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use alba_core::git::changed_files;
11use alba_core::{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<PathBuf, Vec<(String, &str)>> = HashMap::new();
137 for beam in &project.beams {
138 for pattern in &beam.inputs {
139 let (anchor, rest) = alba_core::anchor_pattern(&beam.dir, pattern);
140 groups
141 .entry(anchor)
142 .or_default()
143 .push((rest, beam.id.0.as_str()));
144 }
145 }
146 for (base, patterns) in &groups {
147 let mut builder = globset::GlobSetBuilder::new();
148 let mut owners: Vec<&str> = Vec::new();
149 for (pattern, owner) in patterns {
150 if let Ok(glob) = globset::Glob::new(pattern) {
151 builder.add(glob);
152 owners.push(owner);
153 }
154 }
155 let Ok(set) = builder.build() else { continue };
156 for path in changed {
157 let Ok(relative) = path.strip_prefix(base) else {
158 continue;
159 };
160 let unified = relative.to_string_lossy().replace('\\', "/");
161 let candidate = globset::Candidate::new(&unified);
162 for index in set.matches_candidate(&candidate) {
163 affected.insert(owners[index]);
164 }
165 }
166 }
167
168 loop {
172 let before = affected.len();
173 for beam in &project.beams {
174 if beam
175 .needs
176 .iter()
177 .any(|need| affected.contains(need.value.0.as_str()))
178 {
179 affected.insert(beam.id.0.as_str());
180 }
181 }
182 if affected.len() == before {
183 break;
184 }
185 }
186
187 project
188 .beams
189 .iter()
190 .filter(|beam| affected.contains(beam.id.0.as_str()))
191 .map(|beam| beam.id.clone())
192 .collect()
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use std::time::{Duration, Instant};
199
200 #[test]
204 fn affected_selection_stays_imperceptible() {
205 let mut source = String::new();
206 for i in 0..300 {
207 let needs = if i == 0 {
208 String::new()
209 } else {
210 format!("needs [b{}] ", i - 1)
211 };
212 source.push_str(&format!(
213 "beam b{i} {{ {needs}inputs [\"src/m{i}/**\"] run \"x\" }}\n"
214 ));
215 }
216 let dir = tempfile::tempdir().unwrap();
217 std::fs::write(dir.path().join("Beamfile"), &source).unwrap();
218 let (project, sources) = alba_core::load_project(&dir.path().join("Beamfile")).unwrap();
219 let changed: Vec<PathBuf> = (0..2000)
220 .map(|i| dir.path().join(format!("src/m{}/f{i}.rs", i % 300)))
221 .collect();
222
223 let mut samples: Vec<Duration> = (0..15)
224 .map(|_| {
225 let start = Instant::now();
226 let affected = affected_beams(&project, &sources, &changed);
227 assert_eq!(affected.len(), 300);
228 start.elapsed()
229 })
230 .collect();
231 samples.sort();
232 let median = samples[samples.len() / 2];
233 assert!(median < Duration::from_millis(50), "median {median:?}");
234 }
235}