use crate::error::RailResult;
use crate::workspace::WorkspaceContext;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use crate::change_detection::classify::{FileProfile, classify_path};
#[derive(Debug, Default)]
pub struct ChangeCategories {
pub source_files: Vec<PathBuf>,
pub test_files: Vec<PathBuf>,
pub example_files: Vec<PathBuf>,
pub doc_files: Vec<PathBuf>,
pub config_files: Vec<PathBuf>,
pub build_scripts: Vec<PathBuf>,
pub other_files: Vec<PathBuf>,
}
impl ChangeCategories {
pub fn is_docs_only(&self) -> bool {
self.source_files.is_empty()
&& self.test_files.is_empty()
&& self.example_files.is_empty()
&& self.config_files.is_empty()
&& self.build_scripts.is_empty()
&& !self.doc_files.is_empty()
}
pub fn has_source_changes(&self) -> bool {
!self.source_files.is_empty() || !self.build_scripts.is_empty()
}
pub fn has_test_changes(&self) -> bool {
!self.test_files.is_empty()
}
pub fn has_config_changes(&self) -> bool {
!self.config_files.is_empty()
}
pub fn has_example_changes(&self) -> bool {
!self.example_files.is_empty()
}
}
#[derive(Debug)]
pub struct ImpactReport {
pub changed_files: Vec<(PathBuf, char)>,
pub direct_crates: Vec<String>,
pub transitive_crates: Vec<String>,
pub categories: ChangeCategories,
pub requires_rebuild: bool,
pub requires_retest: bool,
}
impl ImpactReport {
pub fn all_affected_crates(&self) -> Vec<String> {
let mut all: HashSet<String> = self.direct_crates.iter().cloned().collect();
all.extend(self.transitive_crates.iter().cloned());
let mut result: Vec<_> = all.into_iter().collect();
result.sort();
result
}
pub fn affects_crate(&self, crate_name: &str) -> bool {
self.direct_crates.iter().any(|name| name == crate_name)
|| self.transitive_crates.iter().any(|name| name == crate_name)
}
pub fn minimal_test_set(&self) -> Vec<String> {
self.all_affected_crates()
}
}
pub struct ChangeImpact<'a> {
ctx: &'a WorkspaceContext,
}
impl<'a> ChangeImpact<'a> {
pub fn new(ctx: &'a WorkspaceContext) -> Self {
Self { ctx }
}
pub fn analyze_changes(&self, from: &str, to: Option<&str>) -> RailResult<ImpactReport> {
let git_changed_files = self.ctx.git()?.git().get_changed_files_between(from, to)?;
let changed_files: Vec<(PathBuf, char)> = git_changed_files
.into_iter()
.filter_map(|(git_path, change_type)| {
self
.ctx
.to_workspace_path(&git_path)
.map(|ws_path| (ws_path, change_type))
})
.collect();
let categories = self.categorize_changes(&changed_files);
let file_paths: Vec<&Path> = changed_files.iter().map(|(p, _)| p.as_path()).collect();
let analysis = crate::graph::analyze(&self.ctx.graph, &file_paths)?;
let requires_rebuild = categories.has_source_changes() || categories.has_config_changes();
let requires_retest = requires_rebuild || categories.has_test_changes() || categories.has_example_changes();
let mut direct_crates: Vec<String> = analysis.impact.direct.into_iter().collect();
direct_crates.sort();
let mut transitive_crates: Vec<String> = analysis.impact.dependents.into_iter().collect();
transitive_crates.sort();
Ok(ImpactReport {
changed_files,
direct_crates,
transitive_crates,
categories,
requires_rebuild,
requires_retest,
})
}
pub fn analyze_crate_changes(
&self,
crate_name: &str,
from: &str,
to: Option<&str>,
) -> RailResult<Option<ImpactReport>> {
let full_report = self.analyze_changes(from, to)?;
if !full_report.affects_crate(crate_name) {
return Ok(None);
}
Ok(Some(full_report))
}
fn categorize_changes(&self, files: &[(PathBuf, char)]) -> ChangeCategories {
let mut categories = ChangeCategories::default();
for (path, _change_type) in files {
match classify_path(path) {
FileProfile::RustSrc => categories.source_files.push(path.clone()),
FileProfile::RustTest | FileProfile::RustBench => categories.test_files.push(path.clone()),
FileProfile::RustExample => categories.example_files.push(path.clone()),
FileProfile::RustBuildScript => categories.build_scripts.push(path.clone()),
FileProfile::TomlManifest
| FileProfile::TomlWorkspace
| FileProfile::TomlCargoConfig
| FileProfile::TomlRustToolchain
| FileProfile::TomlTooling
| FileProfile::CargoLock => {
categories.config_files.push(path.clone());
}
FileProfile::Docs | FileProfile::RepoConfig => categories.doc_files.push(path.clone()),
FileProfile::Ci | FileProfile::Script | FileProfile::Unknown => categories.other_files.push(path.clone()),
}
}
categories
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_context() -> WorkspaceContext {
let current_dir = std::env::current_dir().unwrap();
WorkspaceContext::build(¤t_dir).unwrap()
}
#[test]
fn test_proc_macro_detection() {
let ctx = create_test_context();
assert!(
!ctx.cargo.is_proc_macro("cargo-rail"),
"cargo-rail should not be detected as proc-macro crate"
);
let proc_macros = ctx.cargo.proc_macro_crates();
assert!(
!proc_macros.contains("cargo-rail"),
"cargo-rail should not be in proc_macro_crates set"
);
}
#[test]
fn test_change_categories_docs_only() {
let mut cat = ChangeCategories::default();
cat.doc_files.push(PathBuf::from("README.md"));
assert!(cat.is_docs_only());
assert!(!cat.has_source_changes());
assert!(!cat.has_test_changes());
}
#[test]
fn test_change_categories_source_changes() {
let mut cat = ChangeCategories::default();
cat.source_files.push(PathBuf::from("src/lib.rs"));
assert!(!cat.is_docs_only());
assert!(cat.has_source_changes());
}
#[test]
fn test_change_categories_example_changes() {
let mut cat = ChangeCategories::default();
cat.example_files.push(PathBuf::from("examples/demo.rs"));
assert!(!cat.is_docs_only());
assert!(cat.has_example_changes());
assert!(!cat.has_source_changes());
}
#[test]
fn test_categorize_comprehensive() {
let ctx = create_test_context();
let analyzer = ChangeImpact::new(&ctx);
let files = vec![
(PathBuf::from("src/lib.rs"), 'M'),
(PathBuf::from("tests/integration.rs"), 'M'),
(PathBuf::from("examples/demo.rs"), 'M'),
(PathBuf::from("README.md"), 'M'),
(PathBuf::from("Cargo.toml"), 'M'),
(PathBuf::from("build.rs"), 'M'),
(PathBuf::from(".cargo/config.toml"), 'M'),
];
let cat = analyzer.categorize_changes(&files);
assert_eq!(cat.source_files.len(), 1, "Expected 1 source file");
assert_eq!(cat.test_files.len(), 1, "Expected 1 test file");
assert_eq!(cat.example_files.len(), 1, "Expected 1 example file");
assert_eq!(cat.doc_files.len(), 1, "Expected 1 doc file");
assert_eq!(
cat.config_files.len(),
2,
"Expected 2 config files (Cargo.toml + .cargo/config.toml)"
);
assert_eq!(cat.build_scripts.len(), 1, "Expected 1 build script");
}
}