use globset::{Glob, GlobSet, GlobSetBuilder};
use std::path::{Path, PathBuf};
pub fn matches_pattern(files: &[PathBuf], project_root: &Path, pattern: &str) -> bool {
let is_simple_path = !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('[');
for file in files {
let relative_path = normalize_path(file, project_root);
let matched = if is_simple_path {
let pattern_path = Path::new(pattern);
relative_path.starts_with(pattern_path)
} else {
let Ok(glob) = Glob::new(pattern) else {
tracing::trace!(pattern, "Skipping invalid glob pattern");
continue;
};
let Ok(set) = GlobSetBuilder::new().add(glob).build() else {
continue;
};
set.is_match(&relative_path)
};
tracing::trace!(
file = %file.display(),
normalized = %relative_path.display(),
project_root = %project_root.display(),
pattern,
matched,
"Compared changed file against affected pattern"
);
if matched {
return true;
}
}
false
}
pub fn build_glob_set<'a>(patterns: impl Iterator<Item = &'a str>) -> Option<GlobSet> {
let mut builder = GlobSetBuilder::new();
let mut has_patterns = false;
for pattern in patterns {
if let Ok(glob) = Glob::new(pattern) {
builder.add(glob);
has_patterns = true;
} else {
tracing::trace!(pattern, "Skipping invalid glob pattern");
}
}
if !has_patterns {
return None;
}
builder.build().ok()
}
fn normalize_path(file: &Path, project_root: &Path) -> PathBuf {
if project_root == Path::new(".") || project_root.as_os_str().is_empty() {
tracing::trace!(
file = %file.display(),
project_root = %project_root.display(),
normalized = %file.display(),
"Project root is current directory; using file path as-is"
);
return file.to_path_buf();
}
if let Some(stripped) = strip_project_root_prefix(file, project_root) {
tracing::trace!(
file = %file.display(),
project_root = %project_root.display(),
normalized = %stripped.display(),
"Normalized changed file relative to project root"
);
return stripped;
}
tracing::trace!(
file = %file.display(),
project_root = %project_root.display(),
normalized = %file.display(),
"Could not normalize changed file against project root; using original path"
);
file.to_path_buf()
}
fn strip_project_root_prefix(file: &Path, project_root: &Path) -> Option<PathBuf> {
if let Ok(stripped) = file.strip_prefix(project_root) {
return Some(stripped.to_path_buf());
}
if file.is_relative() && project_root.is_absolute() {
return strip_relative_file_with_absolute_project_root(file, project_root);
}
None
}
fn strip_relative_file_with_absolute_project_root(
file: &Path,
project_root: &Path,
) -> Option<PathBuf> {
let root_components: Vec<_> = project_root.components().collect();
for suffix_start in 0..root_components.len() {
let candidate: PathBuf = root_components[suffix_start..]
.iter()
.map(|component| component.as_os_str())
.collect();
if candidate.as_os_str().is_empty() || candidate.is_absolute() {
continue;
}
if file.starts_with(&candidate) {
return file.strip_prefix(&candidate).ok().map(Path::to_path_buf);
}
}
None
}
pub trait AffectedBy {
fn is_affected_by(&self, changed_files: &[PathBuf], project_root: &Path) -> bool;
fn input_patterns(&self) -> Vec<&str>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_matches_pattern_simple_prefix() {
let files = vec![PathBuf::from("crates/foo/bar.rs")];
let root = Path::new(".");
assert!(matches_pattern(&files, root, "crates"));
assert!(matches_pattern(&files, root, "crates/foo"));
assert!(matches_pattern(&files, root, "crates/foo/bar.rs"));
}
#[test]
fn test_matches_pattern_no_match() {
let files = vec![PathBuf::from("src/lib.rs")];
let root = Path::new(".");
assert!(!matches_pattern(&files, root, "crates"));
assert!(!matches_pattern(&files, root, "tests"));
}
#[test]
fn test_matches_pattern_glob() {
let files = vec![PathBuf::from("src/lib.rs"), PathBuf::from("src/main.rs")];
let root = Path::new(".");
assert!(matches_pattern(&files, root, "src/*.rs"));
assert!(!matches_pattern(&files, root, "*.txt"));
}
#[test]
fn test_matches_pattern_with_project_root() {
let files = vec![PathBuf::from("projects/website/src/app.rs")];
let root = Path::new("projects/website");
assert!(matches_pattern(&files, root, "src"));
assert!(matches_pattern(&files, root, "src/app.rs"));
}
#[test]
fn test_matches_pattern_different_project() {
let files = vec![PathBuf::from("projects/api/src/main.rs")];
let root = Path::new("projects/website");
assert!(!matches_pattern(&files, root, "src"));
}
#[test]
fn test_normalize_path_relative_file_with_project_root() {
let file = Path::new("projects/website/src/lib.rs");
let root = Path::new("projects/website");
let normalized = normalize_path(file, root);
assert_eq!(normalized, PathBuf::from("src/lib.rs"));
}
#[test]
fn test_normalize_path_dot_root() {
let file = Path::new("src/lib.rs");
let root = Path::new(".");
let normalized = normalize_path(file, root);
assert_eq!(normalized, PathBuf::from("src/lib.rs"));
}
#[test]
fn test_normalize_path_relative_file_with_absolute_project_root() {
let file = Path::new("chat/src/lib.rs");
let root = Path::new("/repo/chat");
let normalized = normalize_path(file, root);
assert_eq!(normalized, PathBuf::from("src/lib.rs"));
}
#[test]
fn test_normalize_path_relative_file_with_nested_absolute_project_root() {
let file = Path::new("infrastructure/waddle.cloud/src/main.rs");
let root = Path::new("/repo/infrastructure/waddle.cloud");
let normalized = normalize_path(file, root);
assert_eq!(normalized, PathBuf::from("src/main.rs"));
}
#[test]
fn test_matches_pattern_with_absolute_project_root() {
let files = vec![PathBuf::from("chat/src/app.rs")];
let root = Path::new("/repo/chat");
assert!(matches_pattern(&files, root, "src"));
assert!(matches_pattern(&files, root, "src/app.rs"));
assert!(matches_pattern(&files, root, "src/**"));
}
#[test]
fn test_build_glob_set() {
let patterns = ["src/**/*.rs", "tests/*.rs"];
let set = build_glob_set(patterns.iter().copied()).unwrap();
assert!(set.is_match("src/lib.rs"));
assert!(set.is_match("src/foo/bar.rs"));
assert!(set.is_match("tests/test.rs"));
assert!(!set.is_match("docs/readme.md"));
}
#[test]
fn test_build_glob_set_invalid_patterns() {
let patterns = ["[invalid", "src/**"];
let set = build_glob_set(patterns.iter().copied()).unwrap();
assert!(set.is_match("src/lib.rs"));
}
#[test]
fn test_build_glob_set_empty() {
let patterns: [&str; 0] = [];
let set = build_glob_set(patterns.iter().copied());
assert!(set.is_none());
}
}