Skip to main content

alba_engine/
affected.rs

1//! Which beams a git diff touches: the `--affected <ref>` selection.
2//!
3//! [`affected_beams`] is the pure half, over a loaded project and a list
4//! of absolute changed paths. [`select`] is the whole thing: it asks git
5//! for the paths, then narrows the result to what one run should target.
6
7use 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/// What a run was asked for, before git (if involved) has answered.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Selection {
19    /// `alba run <beam>`: the beam and its subgraph.
20    Beam(BeamId),
21    /// `alba run --affected <ref> [beam]`: what changed since `reference`,
22    /// narrowed to `within`'s subgraph when a beam was named.
23    Affected {
24        reference: String,
25        within: Option<BeamId>,
26    },
27}
28
29impl Selection {
30    /// The beam whose subgraph a watch session puts under watch, or
31    /// `None` for the whole project.
32    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
40/// Every beam affected by what changed since `reference`, in project
41/// order: the beams whose `inputs` match a changed path or whose Beamfile
42/// changed, closed over their dependents. `root` is the project root git
43/// runs from; paths git reports are relative to it.
44pub 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
57/// [`affected`] narrowed to one run's targets. `Beam` is validated here
58/// (an unknown beam is the same `CoreError` a plain run reports), so a
59/// caller can rely on `select` for the exit-2-at-startup rule.
60pub 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                // Closure over dependents means: if anything in the
75                // subgraph is affected, so is its root. The subgraph then
76                // runs as usual, the cache skipping what did not move.
77                Some(id) => {
78                    execution_subgraph(project, id)?;
79                    if affected.contains(id) {
80                        vec![id.clone()]
81                    } else {
82                        Vec::new()
83                    }
84                }
85                // Nothing can bind a parameterized beam's arguments in a
86                // run with several targets; `alba affected` still lists it.
87                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
105/// The pure computation: `changed` holds absolute paths (existing or not).
106pub(crate) fn affected_beams(
107    project: &Project,
108    sources: &SourceMap,
109    changed: &[PathBuf],
110) -> Vec<BeamId> {
111    // No filesystem touched, deliberately: `changed` and every `beam.dir`
112    // are already absolute (the caller's `root.join(..)` and the loader's
113    // own `std::path::absolute`, respectively), built from the same root,
114    // so a lexical prefix match is both correct and syscall-free — unlike
115    // `watch::set::normalize`, which exists to reconcile paths a live OS
116    // watcher resolved through a symlink, a problem git-diff paths do not
117    // have.
118    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    // Patterns sharing an anchor (the common case: one Beamfile, no
131    // imports, no `..`) share the same relative view of `changed`, and are
132    // compiled into one `GlobSet` per anchor rather than one per beam:
133    // compiling is what dominates, so matching each changed path once
134    // against the combined set — instead of once per beam's own set —
135    // is what keeps this linear rather than quadratic in beams.
136    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    // Closure over dependents. ponytail: fixpoint over the whole beam
169    // list, quadratic in beams; a reverse adjacency map if a project ever
170    // has thousands of them.
171    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    /// The spec's guard: on the order of ten milliseconds, git's own time
201    /// excluded. 300 beams in a chain, each with an input pattern, against
202    /// 2000 changed paths; the median of 15 samples, as the other guards do.
203    #[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}