use crate::detectors::base::{Detector, DetectorConfig};
use crate::graph::GraphStore;
use crate::models::{Finding, Severity};
use anyhow::Result;
use regex::Regex;
use std::collections::HashSet;
use std::path::PathBuf;
use tracing::{debug, info};
use uuid::Uuid;
const DEFAULT_MIN_FUNCTION_LOC: usize = 5;
const DEFAULT_EXCLUDE_PRIVATE: bool = true;
const DEFAULT_EXCLUDE_DUNDER: bool = true;
const DEFAULT_MAX_FINDINGS: usize = 50;
const TEST_FILE_PATTERNS: &[&str] = &[
r"test_.*\.py$",
r".*_test\.py$",
r"tests?/.*\.py$",
r".*tests?\.py$",
r".*\.test\.[jt]sx?$",
r".*\.spec\.[jt]sx?$",
r"__tests__/.*\.[jt]sx?$",
];
pub struct AIMissingTestsDetector {
config: DetectorConfig,
min_function_loc: usize,
exclude_private: bool,
exclude_dunder: bool,
max_findings: usize,
test_file_patterns: Vec<Regex>,
}
impl AIMissingTestsDetector {
pub fn new() -> Self {
let test_file_patterns = TEST_FILE_PATTERNS
.iter()
.filter_map(|p| Regex::new(p).ok())
.collect();
Self {
config: DetectorConfig::new(),
min_function_loc: DEFAULT_MIN_FUNCTION_LOC,
exclude_private: DEFAULT_EXCLUDE_PRIVATE,
exclude_dunder: DEFAULT_EXCLUDE_DUNDER,
max_findings: DEFAULT_MAX_FINDINGS,
test_file_patterns,
}
}
pub fn with_config(config: DetectorConfig) -> Self {
let test_file_patterns = TEST_FILE_PATTERNS
.iter()
.filter_map(|p| Regex::new(p).ok())
.collect();
Self {
min_function_loc: config.get_option_or("min_function_loc", DEFAULT_MIN_FUNCTION_LOC),
exclude_private: config.get_option_or("exclude_private", DEFAULT_EXCLUDE_PRIVATE),
exclude_dunder: config.get_option_or("exclude_dunder", DEFAULT_EXCLUDE_DUNDER),
max_findings: config.get_option_or("max_findings", DEFAULT_MAX_FINDINGS),
config,
test_file_patterns,
}
}
fn is_test_file(&self, file_path: &str) -> bool {
let file_lower = file_path.to_lowercase();
self.test_file_patterns
.iter()
.any(|p| p.is_match(&file_lower))
}
fn should_skip_function(&self, name: &str, file_path: &str) -> bool {
if name.is_empty() {
return true;
}
let name_lower = name.to_lowercase();
if name_lower.starts_with("test") || name_lower.ends_with("_test") {
return true;
}
if self.is_test_file(file_path) {
return true;
}
if self.exclude_private && name.starts_with('_') && !name.starts_with("__") {
return true;
}
if self.exclude_dunder && name.starts_with("__") && name.ends_with("__") {
return true;
}
false
}
fn get_test_function_variants(&self, func_name: &str) -> Vec<String> {
let name_lower = func_name.to_lowercase();
let mut variants = vec![
format!("test_{}", name_lower),
format!("test{}", name_lower),
format!("{}_test", name_lower),
];
if name_lower.contains('_') {
for part in name_lower.split('_') {
if part.len() > 2 {
variants.push(format!("test_{}", part));
}
}
}
variants
}
fn get_test_file_variants(&self, file_path: &str) -> Vec<String> {
let normalized = file_path.replace('\\', "/");
let parts: Vec<&str> = normalized.split('/').collect();
let filename = parts.last().unwrap_or(&"");
let module_name = if filename.contains('.') {
filename
.rsplit_once('.')
.map(|(name, _)| name)
.unwrap_or(filename)
} else {
filename
};
if module_name.is_empty() {
return vec![];
}
vec![
format!("test_{}.py", module_name),
format!("tests/test_{}.py", module_name),
format!("test/test_{}.py", module_name),
format!("{}_test.py", module_name),
format!("tests/{}_test.py", module_name),
format!("{}.test.js", module_name),
format!("{}.spec.js", module_name),
format!("{}.test.ts", module_name),
format!("{}.spec.ts", module_name),
format!("__tests__/{}.js", module_name),
format!("__tests__/{}.ts", module_name),
]
}
fn generate_test_suggestion(&self, func_name: &str, language: &str) -> String {
let lang = language.to_lowercase();
if lang == "python" || lang.is_empty() {
format!(
r#"Create a comprehensive test for '{}':
```python
def test_{}_success():
"""Test {} with valid input."""
result = {}(valid_input)
assert result is not None
assert result == expected_value
def test_{}_edge_cases():
"""Test {} edge cases."""
# Test boundary conditions
assert {}(min_value) == expected_min
assert {}(max_value) == expected_max
def test_{}_error_handling():
"""Test {} error handling."""
with pytest.raises(ValueError):
{}(invalid_input)
```"#,
func_name,
func_name,
func_name,
func_name,
func_name,
func_name,
func_name,
func_name,
func_name,
func_name,
func_name
)
} else if lang == "javascript" || lang == "typescript" {
format!(
r#"Create a comprehensive test for '{}':
```{}
describe('{}', () => {{
it('should handle valid input', () => {{
const result = {}(validInput);
expect(result).toBeDefined();
expect(result).toEqual(expectedValue);
}});
it('should handle edge cases', () => {{
expect({}(minValue)).toEqual(expectedMin);
expect({}(maxValue)).toEqual(expectedMax);
}});
it('should throw on invalid input', () => {{
expect(() => {}(invalidInput)).toThrow();
}});
}});
```"#,
func_name, lang, func_name, func_name, func_name, func_name, func_name
)
} else {
format!(
"Add comprehensive test coverage for '{}' with multiple assertions and error handling tests.",
func_name
)
}
}
fn create_finding(
&self,
qualified_name: &str,
name: &str,
file_path: &str,
line_start: Option<u32>,
line_end: Option<u32>,
loc: usize,
is_method: bool,
language: &str,
) -> Finding {
let func_type = if is_method { "method" } else { "function" };
let description = format!(
"The {} '{}' has no corresponding test. \
This is a common pattern when AI generates implementation code without tests.{}",
func_type,
name,
if loc > 0 {
format!(" The {} has {} lines of code.", func_type, loc)
} else {
String::new()
}
);
Finding {
id: Uuid::new_v4().to_string(),
detector: "AIMissingTestsDetector".to_string(),
severity: Severity::Medium,
title: format!("Missing tests for {}: {}", func_type, name),
description,
affected_files: vec![PathBuf::from(file_path)],
line_start,
line_end,
suggested_fix: Some(self.generate_test_suggestion(name, language)),
estimated_effort: Some("Small (15-45 minutes)".to_string()),
category: Some("test_coverage".to_string()),
cwe_id: None,
why_it_matters: Some(
"Untested code is a risk. Tests catch bugs early, document expected behavior, \
and make refactoring safer. AI-generated code especially needs tests since \
AI may produce subtly incorrect implementations."
.to_string(),
),
..Default::default()
}
}
}
impl Default for AIMissingTestsDetector {
fn default() -> Self {
Self::new()
}
}
impl Detector for AIMissingTestsDetector {
fn name(&self) -> &'static str {
"AIMissingTestsDetector"
}
fn description(&self) -> &'static str {
"Detects functions/methods that lack corresponding tests"
}
fn category(&self) -> &'static str {
"ai_generated"
}
fn config(&self) -> Option<&DetectorConfig> {
Some(&self.config)
} fn detect(&self, graph: &GraphStore) -> Result<Vec<Finding>> {
let mut findings = Vec::new();
use std::collections::HashSet;
let test_funcs: HashSet<String> = graph.get_functions()
.iter()
.filter(|f| f.name.starts_with("test_") || f.file_path.contains("test"))
.map(|f| f.name.clone())
.collect();
for func in graph.get_functions() {
if func.file_path.contains("test") || func.name.starts_with("_") {
continue;
}
let complexity = func.complexity().unwrap_or(1);
let loc = func.loc();
if complexity < 5 && loc < 20 {
continue;
}
let test_name = format!("test_{}", func.name);
if !test_funcs.contains(&test_name) && !test_funcs.iter().any(|t| t.contains(&func.name)) {
let severity = if complexity > 15 {
Severity::High
} else if complexity > 10 {
Severity::Medium
} else {
Severity::Low
};
findings.push(Finding {
id: Uuid::new_v4().to_string(),
detector: "AIMissingTestsDetector".to_string(),
severity,
title: format!("Missing Test: {}", func.name),
description: format!(
"Function '{}' (complexity: {}, {} LOC) has no test coverage.",
func.name, complexity, loc
),
affected_files: vec![func.file_path.clone().into()],
line_start: Some(func.line_start),
line_end: Some(func.line_end),
suggested_fix: Some(format!("Add test function: test_{}", func.name)),
estimated_effort: Some("Small (30 min)".to_string()),
category: Some("ai_watchdog".to_string()),
cwe_id: None,
why_it_matters: Some("Complex untested code is a maintenance risk".to_string()),
..Default::default()
});
}
}
findings.truncate(50);
Ok(findings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_test_file() {
let detector = AIMissingTestsDetector::new();
assert!(detector.is_test_file("test_module.py"));
assert!(detector.is_test_file("module_test.py"));
assert!(detector.is_test_file("tests/module.py"));
assert!(detector.is_test_file("app.test.js"));
assert!(detector.is_test_file("app.spec.ts"));
assert!(detector.is_test_file("__tests__/app.js"));
assert!(!detector.is_test_file("module.py"));
assert!(!detector.is_test_file("app.js"));
}
#[test]
fn test_should_skip_function() {
let detector = AIMissingTestsDetector::new();
assert!(detector.should_skip_function("test_something", "module.py"));
assert!(detector.should_skip_function("something_test", "module.py"));
assert!(detector.should_skip_function("helper", "test_module.py"));
assert!(detector.should_skip_function("_private", "module.py"));
assert!(detector.should_skip_function("__init__", "module.py"));
assert!(!detector.should_skip_function("process_data", "module.py"));
}
#[test]
fn test_get_test_function_variants() {
let detector = AIMissingTestsDetector::new();
let variants = detector.get_test_function_variants("process_data");
assert!(variants.contains(&"test_process_data".to_string()));
assert!(variants.contains(&"testprocess_data".to_string()));
assert!(variants.contains(&"process_data_test".to_string()));
assert!(variants.contains(&"test_process".to_string()));
assert!(variants.contains(&"test_data".to_string()));
}
}