use std::collections::HashSet;
use std::path::{Path, PathBuf};
use alba_core::{BeamId, CoreError, Project, SourceMap, execution_subgraph, expand_globs};
pub(crate) enum Relevance {
Beamfile,
Input,
Irrelevant,
}
pub(crate) struct WatchSet {
beamfiles: Vec<PathBuf>,
groups: Vec<InputGroup>,
}
struct InputGroup {
base: PathBuf,
patterns: Vec<String>,
set: globset::GlobSet,
files: HashSet<PathBuf>,
}
impl WatchSet {
pub(crate) fn new(
project: &Project,
target: Option<&BeamId>,
sources: &SourceMap,
) -> Result<Self, CoreError> {
let subgraph = target
.map(|id| execution_subgraph(project, id))
.transpose()?;
let mut groups: Vec<InputGroup> = Vec::new();
for beam in project.beams.iter().filter(|beam| {
subgraph
.as_ref()
.is_none_or(|subgraph| subgraph.contains(&beam.id))
}) {
for pattern in &beam.inputs {
let (anchor, rest) = alba_core::anchor_pattern(&beam.dir, pattern);
let base = normalize(&anchor);
match groups.iter_mut().find(|group| group.base == base) {
Some(group) => {
if !group.patterns.contains(&rest) {
group.patterns.push(rest);
}
}
None => groups.push(InputGroup {
base,
patterns: vec![rest],
set: globset::GlobSet::empty(),
files: HashSet::new(),
}),
}
}
}
for group in &mut groups {
group.compile();
group.expand();
}
Ok(Self {
beamfiles: sources.paths().map(normalize).collect(),
groups,
})
}
pub(crate) fn file_count(&self) -> usize {
self.groups.iter().map(|group| group.files.len()).sum()
}
pub(crate) fn classify(&mut self, path: &Path) -> Relevance {
let path = normalize(path);
if path
.components()
.any(|c| c.as_os_str() == ".git" || c.as_os_str() == ".alba")
{
return Relevance::Irrelevant;
}
if self.beamfiles.contains(&path) {
return Relevance::Beamfile;
}
for group in &mut self.groups {
if group.concerns(&path) {
return Relevance::Input;
}
}
Relevance::Irrelevant
}
}
impl InputGroup {
fn compile(&mut self) {
let mut builder = globset::GlobSetBuilder::new();
for pattern in &self.patterns {
if let Ok(glob) = globset::Glob::new(pattern) {
builder.add(glob);
}
}
self.set = builder
.build()
.unwrap_or_else(|_| globset::GlobSet::empty());
}
fn expand(&mut self) {
self.files = expand_globs(&self.base, &self.patterns)
.into_iter()
.map(|(_, absolute)| normalize(&absolute))
.collect();
}
fn concerns(&mut self, path: &Path) -> bool {
let Ok(relative) = path.strip_prefix(&self.base) else {
return false;
};
let unified = relative.to_string_lossy().replace('\\', "/");
if !self.set.is_match(&unified) {
return false;
}
if self.files.contains(path) {
return true;
}
self.expand();
self.files.contains(path)
}
}
fn normalize(path: &Path) -> PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
match (path.parent(), path.file_name()) {
(Some(parent), Some(name)) => match parent.canonicalize() {
Ok(parent) => parent.join(name),
Err(_) => path.to_path_buf(),
},
_ => path.to_path_buf(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use alba_core::{BeamId, load_project};
fn project_on_disk() -> (tempfile::TempDir, WatchSet) {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "target/\nsrc/generated/\n").unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::create_dir_all(dir.path().join("src/generated")).unwrap();
std::fs::create_dir_all(dir.path().join("target")).unwrap();
std::fs::write(dir.path().join("src/lib.rs"), "fn main() {}").unwrap();
std::fs::write(dir.path().join("src/generated/gen.rs"), "generated").unwrap();
std::fs::write(dir.path().join("target/gen.rs"), "generated").unwrap();
std::fs::write(
dir.path().join("Beamfile"),
"beam build { inputs [\"src/**/*.rs\"] run \"echo build\" }\n\
beam free { run \"echo free\" }\n",
)
.unwrap();
let (project, sources) = load_project(&dir.path().join("Beamfile")).unwrap();
let set = WatchSet::new(&project, Some(&BeamId("build".to_string())), &sources).unwrap();
(dir, set)
}
#[test]
fn a_loaded_beamfile_classifies_as_beamfile() {
let (dir, mut set) = project_on_disk();
assert!(matches!(
set.classify(&dir.path().join("Beamfile")),
Relevance::Beamfile
));
}
#[test]
fn a_matching_source_file_classifies_as_input() {
let (dir, mut set) = project_on_disk();
assert!(matches!(
set.classify(&dir.path().join("src/lib.rs")),
Relevance::Input
));
}
#[test]
fn a_gitignored_file_never_triggers() {
let (dir, mut set) = project_on_disk();
for gitignored in ["target/gen.rs", "src/generated/gen.rs"] {
assert!(matches!(
set.classify(&dir.path().join(gitignored)),
Relevance::Irrelevant
));
}
}
#[test]
fn an_unrelated_file_never_triggers() {
let (dir, mut set) = project_on_disk();
assert!(matches!(
set.classify(&dir.path().join("README.md")),
Relevance::Irrelevant
));
}
#[test]
fn a_file_created_after_startup_classifies_as_input() {
let (dir, mut set) = project_on_disk();
std::fs::write(dir.path().join("src/new.rs"), "fn new() {}").unwrap();
assert!(matches!(
set.classify(&dir.path().join("src/new.rs")),
Relevance::Input
));
}
#[test]
fn a_deleted_watched_file_classifies_as_input() {
let (dir, mut set) = project_on_disk();
let file = dir.path().join("src/lib.rs");
set.classify(&file); std::fs::remove_file(&file).unwrap();
assert!(matches!(set.classify(&file), Relevance::Input));
}
#[test]
fn alba_and_git_directories_are_excluded_outright() {
let (dir, mut set) = project_on_disk();
for special in [".alba/cache/build.json", ".git/index"] {
assert!(matches!(
set.classify(&dir.path().join(special)),
Relevance::Irrelevant
));
}
}
#[test]
fn file_count_counts_the_resolved_inputs() {
let (_dir, set) = project_on_disk();
assert_eq!(set.file_count(), 1);
}
#[test]
fn an_input_above_its_beamfile_classifies_as_input() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("contexts/shared")).unwrap();
std::fs::write(dir.path().join("Dockerfile"), "FROM php").unwrap();
std::fs::write(
dir.path().join("Beamfile"),
"import \"contexts/shared/Beamfile\" as shared\n",
)
.unwrap();
std::fs::write(
dir.path().join("contexts/shared/Beamfile"),
"beam test { inputs [\"../../Dockerfile\"] run \"echo test\" }\n",
)
.unwrap();
let (project, sources) = load_project(&dir.path().join("Beamfile")).unwrap();
let mut set = WatchSet::new(&project, None, &sources).unwrap();
assert!(matches!(
set.classify(&dir.path().join("Dockerfile")),
Relevance::Input
));
}
#[test]
fn an_unknown_target_is_a_core_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Beamfile"), "beam a { run \"echo a\" }\n").unwrap();
let (project, sources) = load_project(&dir.path().join("Beamfile")).unwrap();
assert!(WatchSet::new(&project, Some(&BeamId("missing".to_string())), &sources).is_err());
}
}