use super::{
assertion_detector::AssertionDetector,
complexity_scorer::{ComplexityScorer, TestComplexityScore},
flaky_detector::FlakyDetector,
framework_detector::FrameworkDetector,
test_classifier::TestClassifier,
RustFlakinessType, RustTestFramework, RustTestIssueType, RustTestQualityIssue,
RustTestSeverity,
};
use std::path::Path;
use syn::visit::Visit;
use syn::{File, ItemFn};
pub struct RustTestQualityAnalyzer {
test_classifier: TestClassifier,
assertion_detector: AssertionDetector,
complexity_scorer: ComplexityScorer,
flaky_detector: FlakyDetector,
framework_detector: FrameworkDetector,
complexity_threshold: f32,
}
impl RustTestQualityAnalyzer {
pub const DEFAULT_COMPLEXITY_THRESHOLD: f32 = 10.0;
pub fn new() -> Self {
Self {
test_classifier: TestClassifier::new(),
assertion_detector: AssertionDetector::new(),
complexity_scorer: ComplexityScorer::new(),
flaky_detector: FlakyDetector::new(),
framework_detector: FrameworkDetector::new(),
complexity_threshold: Self::DEFAULT_COMPLEXITY_THRESHOLD,
}
}
pub fn with_threshold(threshold: f32) -> Self {
Self {
complexity_threshold: threshold,
..Self::new()
}
}
pub fn analyze_file(&mut self, file: &File, path: &Path) -> Vec<RustTestQualityIssue> {
let mut visitor = TestFunctionVisitor::new(path);
visitor.visit_file(file);
let test_functions = visitor.test_functions;
test_functions
.into_iter()
.flat_map(|func| self.analyze_test_function(&func, path))
.collect()
}
pub fn analyze_test_function(
&mut self,
func: &ItemFn,
path: &Path,
) -> Vec<RustTestQualityIssue> {
if !self.test_classifier.is_test_function(func) {
return vec![];
}
let mut issues = Vec::new();
let test_name = func.sig.ident.to_string();
let line = func.sig.ident.span().start().line;
let framework = self.framework_detector.detect_framework(func);
let _test_type = self.test_classifier.classify_test_type(func, path);
let skip_assertion_check = matches!(
framework,
RustTestFramework::Proptest
| RustTestFramework::Quickcheck
| RustTestFramework::Criterion
);
let assertions = self.assertion_detector.analyze_assertions(func);
let assertion_count = assertions.len();
if !skip_assertion_check && assertion_count == 0 {
issues.push(RustTestQualityIssue {
issue_type: RustTestIssueType::NoAssertions,
test_name: test_name.clone(),
line,
severity: RustTestSeverity::High,
confidence: 0.95,
explanation: "Test function has no assertions, so it cannot verify correctness"
.to_string(),
suggestion:
"Add assertions like assert!, assert_eq!, or assert_ne! to verify behavior"
.to_string(),
});
}
let complexity_score = self
.complexity_scorer
.calculate_complexity(func, assertion_count);
if complexity_score.total_score > self.complexity_threshold {
issues.push(self.create_complexity_issue(test_name.clone(), line, complexity_score));
}
let flaky_indicators = self.flaky_detector.detect_flaky_patterns(func);
for indicator in flaky_indicators {
issues.push(RustTestQualityIssue {
issue_type: RustTestIssueType::FlakyPattern(indicator.flakiness_type.clone()),
test_name: test_name.clone(),
line: indicator.line,
severity: self.assess_flakiness_severity(&indicator.flakiness_type),
confidence: 0.85,
explanation: indicator.explanation.clone(),
suggestion: self.get_flakiness_suggestion(&indicator.flakiness_type),
});
}
issues
}
fn create_complexity_issue(
&self,
test_name: String,
line: usize,
score: TestComplexityScore,
) -> RustTestQualityIssue {
let explanation = format!(
"Test complexity score ({:.1}) exceeds threshold ({:.1}). Factors: {} conditionals, {} loops, {} assertions, max nesting {}, {} lines",
score.total_score,
self.complexity_threshold,
score.factors.conditionals,
score.factors.loops,
score.factors.assertions,
score.factors.nesting_depth,
score.factors.line_count
);
let suggestion = if score.factors.loops > 0 {
"Consider extracting loop logic into helper functions or parameterizing the test"
.to_string()
} else if score.factors.conditionals > 2 {
"Consider splitting into multiple focused test cases".to_string()
} else if score.factors.line_count > 50 {
"Consider breaking into smaller test functions".to_string()
} else {
"Simplify test logic or split into multiple tests".to_string()
};
RustTestQualityIssue {
issue_type: RustTestIssueType::TooComplex(score.total_score as u32),
test_name,
line,
severity: if score.total_score > self.complexity_threshold * 2.0 {
RustTestSeverity::High
} else {
RustTestSeverity::Medium
},
confidence: 0.9,
explanation,
suggestion,
}
}
fn assess_flakiness_severity(&self, flakiness_type: &RustFlakinessType) -> RustTestSeverity {
match flakiness_type {
RustFlakinessType::TimingDependency => RustTestSeverity::High,
RustFlakinessType::RandomValue => RustTestSeverity::High,
RustFlakinessType::ThreadingIssue => RustTestSeverity::Critical,
RustFlakinessType::NetworkDependency => RustTestSeverity::High,
RustFlakinessType::ExternalDependency => RustTestSeverity::Medium,
RustFlakinessType::FileSystemDependency => RustTestSeverity::Medium,
RustFlakinessType::HashOrdering => RustTestSeverity::Medium,
}
}
fn get_flakiness_suggestion(&self, flakiness_type: &RustFlakinessType) -> String {
match flakiness_type {
RustFlakinessType::TimingDependency => {
"Use explicit synchronization or mock time instead of sleep/Instant::now"
.to_string()
}
RustFlakinessType::RandomValue => {
"Use fixed test data or seed random generators deterministically".to_string()
}
RustFlakinessType::ThreadingIssue => {
"Add proper synchronization or avoid threading in tests".to_string()
}
RustFlakinessType::NetworkDependency => {
"Mock network calls or use test servers with predictable behavior".to_string()
}
RustFlakinessType::ExternalDependency => {
"Mock external dependencies or use test doubles".to_string()
}
RustFlakinessType::FileSystemDependency => {
"Use tempfile crate or mock filesystem operations".to_string()
}
RustFlakinessType::HashOrdering => {
"Use BTreeMap/BTreeSet or sort results before assertions".to_string()
}
}
}
}
impl Default for RustTestQualityAnalyzer {
fn default() -> Self {
Self::new()
}
}
struct TestFunctionVisitor<'a> {
test_functions: Vec<ItemFn>,
test_classifier: TestClassifier,
_path: &'a Path,
}
impl<'a> TestFunctionVisitor<'a> {
fn new(path: &'a Path) -> Self {
Self {
test_functions: Vec::new(),
test_classifier: TestClassifier::new(),
_path: path,
}
}
}
impl<'a, 'ast> Visit<'ast> for TestFunctionVisitor<'a> {
fn visit_item_fn(&mut self, func: &'ast ItemFn) {
if self.test_classifier.is_test_function(func) {
self.test_functions.push(func.clone());
}
syn::visit::visit_item_fn(self, func);
}
fn visit_impl_item_fn(&mut self, func: &'ast syn::ImplItemFn) {
let item_fn = syn::ItemFn {
attrs: func.attrs.clone(),
vis: func.vis.clone(),
sig: func.sig.clone(),
block: Box::new(func.block.clone()),
};
if self.test_classifier.is_test_function(&item_fn) {
self.test_functions.push(item_fn);
}
syn::visit::visit_impl_item_fn(self, func);
}
}
#[cfg(test)]
mod tests {
use super::*;
use syn::parse_quote;
#[test]
fn test_analyze_test_with_no_assertions() {
let func: ItemFn = parse_quote! {
#[test]
fn test_no_assertions() {
let x = 42;
process(x);
}
};
let mut analyzer = RustTestQualityAnalyzer::new();
let path = Path::new("test.rs");
let issues = analyzer.analyze_test_function(&func, path);
assert!(!issues.is_empty());
assert!(issues
.iter()
.any(|i| matches!(i.issue_type, RustTestIssueType::NoAssertions)));
}
#[test]
fn test_analyze_complex_test() {
let func: ItemFn = parse_quote! {
#[test]
fn test_complex() {
for i in 0..10 {
if i % 2 == 0 {
for j in 0..5 {
assert_eq!(i * j, expected(i, j));
}
}
}
}
};
let mut analyzer = RustTestQualityAnalyzer::with_threshold(5.0);
let path = Path::new("test.rs");
let issues = analyzer.analyze_test_function(&func, path);
assert!(issues
.iter()
.any(|i| matches!(i.issue_type, RustTestIssueType::TooComplex(_))));
}
#[test]
fn test_analyze_flaky_test() {
let func: ItemFn = parse_quote! {
#[test]
fn test_flaky() {
std::thread::sleep(std::time::Duration::from_millis(100));
assert!(true);
}
};
let mut analyzer = RustTestQualityAnalyzer::new();
let path = Path::new("test.rs");
let issues = analyzer.analyze_test_function(&func, path);
assert!(issues
.iter()
.any(|i| matches!(i.issue_type, RustTestIssueType::FlakyPattern(_))));
}
#[test]
fn test_analyze_proper_test() {
let func: ItemFn = parse_quote! {
#[test]
fn test_proper() {
let result = calculate(42);
assert_eq!(result, 84);
}
};
let mut analyzer = RustTestQualityAnalyzer::new();
let path = Path::new("test.rs");
let issues = analyzer.analyze_test_function(&func, path);
assert!(issues.is_empty());
}
#[test]
fn test_skip_assertion_check_for_property_tests() {
let func: ItemFn = parse_quote! {
#[test]
fn test_property(x: i32) {
x == x
}
};
let mut analyzer = RustTestQualityAnalyzer::new();
let path = Path::new("test.rs");
let issues = analyzer.analyze_test_function(&func, path);
assert!(issues
.iter()
.any(|i| matches!(i.issue_type, RustTestIssueType::NoAssertions)));
}
}