use crate::change_detection::classify::{
classify_path, compile_custom_patterns, compile_infrastructure_patterns, custom_surfaces_for_path,
matches_infrastructure_patterns,
};
use crate::config::ChangeDetectionConfig;
use rustc_hash::FxHashMap;
use std::path::Path;
#[derive(Debug, Clone, Default)]
pub struct ChangeClassification {
pub docs_only: bool,
pub rebuild_all: bool,
pub infrastructure_files: Vec<String>,
pub custom_categories: FxHashMap<String, Vec<String>>,
}
impl ChangeClassification {
pub fn skip_tests(&self) -> bool {
self.docs_only
}
pub fn needs_full_rebuild(&self) -> bool {
self.rebuild_all
}
pub fn matched_categories(&self) -> Vec<&str> {
self.custom_categories.keys().map(|s| s.as_str()).collect()
}
}
pub struct ChangeClassifier {
infra_patterns: Vec<glob::Pattern>,
custom_patterns: Vec<(String, glob::Pattern)>,
}
impl ChangeClassifier {
pub fn new(config: &ChangeDetectionConfig) -> Self {
Self {
infra_patterns: compile_infrastructure_patterns(Some(config)),
custom_patterns: compile_custom_patterns(Some(config)),
}
}
pub fn default_config() -> Self {
Self::new(&ChangeDetectionConfig::default())
}
pub fn classify<P: AsRef<Path>>(&self, files: &[P]) -> ChangeClassification {
let mut result = ChangeClassification::default();
let mut has_non_doc_changes = false;
for file in files {
let path = file.as_ref();
let path_str = path.to_string_lossy();
let profile = classify_path(path);
let builtin_infra = profile.default_surfaces().contains(&"infra");
let configured_infra = matches_infrastructure_patterns(&path_str, &self.infra_patterns);
let effective_infra = builtin_infra || configured_infra;
if effective_infra {
result.rebuild_all = true;
if !result
.infrastructure_files
.iter()
.any(|existing| existing == path_str.as_ref())
{
result.infrastructure_files.push(path_str.to_string());
}
has_non_doc_changes = true;
}
for surface in custom_surfaces_for_path(&path_str, &self.custom_patterns) {
let Some(category) = surface.strip_prefix("custom:") else {
continue;
};
let matches = result.custom_categories.entry(category.to_string()).or_default();
if !matches.iter().any(|existing| existing == path_str.as_ref()) {
matches.push(path_str.to_string());
}
}
if !profile.is_docs_only() {
has_non_doc_changes = true;
}
}
result.docs_only = !files.is_empty() && !has_non_doc_changes;
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn default_classifier() -> ChangeClassifier {
ChangeClassifier::default_config()
}
#[test]
fn test_docs_only() {
let classifier = default_classifier();
let files = vec![PathBuf::from("README.md"), PathBuf::from("docs/guide.md")];
let result = classifier.classify(&files);
assert!(result.docs_only, "Should be docs-only");
assert!(!result.rebuild_all, "Should not require rebuild");
assert!(result.infrastructure_files.is_empty());
}
#[test]
fn test_docs_only_with_source() {
let classifier = default_classifier();
let files = vec![PathBuf::from("README.md"), PathBuf::from("src/lib.rs")];
let result = classifier.classify(&files);
assert!(!result.docs_only, "Should not be docs-only with source file");
}
#[test]
fn test_infrastructure_files() {
let classifier = default_classifier();
let files = vec![PathBuf::from(".github/workflows/ci.yml")];
let result = classifier.classify(&files);
assert!(result.rebuild_all, "Should require rebuild for CI changes");
assert_eq!(result.infrastructure_files.len(), 1);
}
#[test]
fn test_custom_categories() {
let mut config = ChangeDetectionConfig::default();
config
.custom
.insert("verify".to_string(), vec!["verify/**/*.rs".to_string()]);
let classifier = ChangeClassifier::new(&config);
let files = vec![PathBuf::from("verify/tests/model.rs")];
let result = classifier.classify(&files);
assert!(result.custom_categories.contains_key("verify"));
assert_eq!(result.custom_categories["verify"].len(), 1);
}
#[test]
fn test_infrastructure_file_can_also_match_custom_category() {
let mut config = ChangeDetectionConfig {
infrastructure: vec![".github/**".to_string()],
..Default::default()
};
config
.custom
.insert("merge_validation".to_string(), vec![".github/workflows/**".to_string()]);
let classifier = ChangeClassifier::new(&config);
let files = vec![PathBuf::from(".github/workflows/ci.yml")];
let result = classifier.classify(&files);
assert!(result.rebuild_all, "workflow changes should still require rebuild_all");
assert_eq!(
result.infrastructure_files,
vec![".github/workflows/ci.yml".to_string()]
);
assert!(
result.custom_categories.contains_key("merge_validation"),
"workflow changes should also match custom merge-validation routing"
);
}
#[test]
fn test_empty_files() {
let classifier = default_classifier();
let files: Vec<PathBuf> = vec![];
let result = classifier.classify(&files);
assert!(!result.docs_only, "Empty file list should not be docs-only");
assert!(!result.rebuild_all);
}
#[test]
fn test_justfile_is_infrastructure() {
let classifier = default_classifier();
let files = vec![PathBuf::from("justfile")];
let result = classifier.classify(&files);
assert!(result.rebuild_all, "justfile should trigger rebuild_all");
}
}