use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::callgraph::build_project_call_graph;
use crate::fs::tree::{collect_files, get_file_tree};
use crate::types::{FunctionRef, IgnoreSpec, Language, ProjectCallGraph};
use crate::TldrResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangeImpactReport {
pub changed_files: Vec<PathBuf>,
pub affected_tests: Vec<PathBuf>,
#[serde(default)]
pub affected_test_functions: Vec<TestFunction>,
pub affected_functions: Vec<FunctionRef>,
pub detection_method: String,
#[serde(default)]
pub metadata: Option<ChangeImpactMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestFunction {
pub file: PathBuf,
pub function: String,
pub class: Option<String>,
pub line: u32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChangeImpactMetadata {
pub language: String,
pub call_graph_nodes: usize,
pub call_graph_edges: usize,
pub analysis_depth: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DetectionMethod {
GitHead,
GitBase {
base: String,
},
GitStaged,
GitUncommitted,
Explicit,
Session,
}
impl std::fmt::Display for DetectionMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DetectionMethod::GitHead => write!(f, "git:HEAD"),
DetectionMethod::GitBase { base } => write!(f, "git:{}...HEAD", base),
DetectionMethod::GitStaged => write!(f, "git:staged"),
DetectionMethod::GitUncommitted => write!(f, "git:uncommitted"),
DetectionMethod::Explicit => write!(f, "explicit"),
DetectionMethod::Session => write!(f, "session"),
}
}
}
pub fn change_impact(
project: &Path,
changed_files: Option<&[PathBuf]>,
language: Language,
) -> TldrResult<ChangeImpactReport> {
let (method, explicit) = if let Some(files) = changed_files {
if files.is_empty() {
(DetectionMethod::GitHead, None)
} else {
(DetectionMethod::Explicit, Some(files.to_vec()))
}
} else {
(DetectionMethod::GitHead, None)
};
change_impact_extended(
project,
method,
language,
10, true, &[], explicit,
)
}
pub fn change_impact_extended(
project: &Path,
method: DetectionMethod,
language: Language,
depth: usize,
_include_imports: bool, _test_patterns: &[String], explicit_files: Option<Vec<PathBuf>>,
) -> TldrResult<ChangeImpactReport> {
let (files, actual_method) = match &method {
DetectionMethod::Explicit => {
let files = explicit_files.unwrap_or_default();
(files, method.clone())
}
DetectionMethod::GitHead => {
match detect_git_changes_head(project) {
Ok(files) if !files.is_empty() => (files, method.clone()),
Ok(_) => (vec![], method.clone()), Err(_) => (vec![], DetectionMethod::Session), }
}
DetectionMethod::GitBase { base } => {
match detect_git_changes_base(project, base) {
Ok(files) => (files, method.clone()),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("not found") || err_str.contains("unknown revision") {
return Err(e);
}
(vec![], DetectionMethod::Session)
}
}
}
DetectionMethod::GitStaged => match detect_git_changes_staged(project) {
Ok(files) => (files, method.clone()),
Err(_) => (vec![], DetectionMethod::Session),
},
DetectionMethod::GitUncommitted => match detect_git_changes_uncommitted(project) {
Ok(files) => (files, method.clone()),
Err(_) => (vec![], DetectionMethod::Session),
},
DetectionMethod::Session => (vec![], method.clone()),
};
let changed_files: Vec<PathBuf> = files
.into_iter()
.filter(|f| {
f.extension()
.and_then(|ext| ext.to_str())
.map(|ext| Language::from_extension(ext) == Some(language))
.unwrap_or(false)
})
.collect();
if changed_files.is_empty() {
return Ok(ChangeImpactReport {
changed_files: vec![],
affected_tests: vec![],
affected_test_functions: vec![],
affected_functions: vec![],
detection_method: actual_method.to_string(),
metadata: Some(ChangeImpactMetadata {
language: language.to_string(),
call_graph_nodes: 0,
call_graph_edges: 0,
analysis_depth: Some(depth),
}),
});
}
let call_graph = build_project_call_graph(project, language, None, true)?;
let changed_functions = find_functions_in_files(&call_graph, &changed_files, project);
let affected_functions =
find_affected_functions_with_depth(&call_graph, &changed_functions, depth);
let all_files = get_all_project_files(project, language)?;
let test_files: HashSet<PathBuf> = all_files
.iter()
.filter(|f| is_test_file(f, language))
.cloned()
.collect();
let affected_tests = find_affected_tests(
&test_files,
&changed_files,
&affected_functions,
&call_graph,
);
let affected_test_functions = extract_test_functions_from_files(&affected_tests, language);
Ok(ChangeImpactReport {
changed_files,
affected_tests,
affected_test_functions,
affected_functions,
detection_method: actual_method.to_string(),
metadata: {
let edge_count = call_graph.edges().count();
Some(ChangeImpactMetadata {
language: language.to_string(),
call_graph_nodes: edge_count, call_graph_edges: edge_count,
analysis_depth: Some(depth),
})
},
})
}
fn extract_test_functions_from_files(
test_files: &[PathBuf],
language: Language,
) -> Vec<TestFunction> {
let mut test_functions = Vec::new();
for file in test_files {
if let Ok(content) = std::fs::read_to_string(file) {
test_functions.extend(extract_test_functions_from_content(
file, &content, language,
));
}
}
test_functions
}
fn extract_test_functions_from_content(
file: &Path,
content: &str,
language: Language,
) -> Vec<TestFunction> {
let mut functions = Vec::new();
let mut current_class: Option<String> = None;
for (line_num, line) in content.lines().enumerate() {
let line_num = line_num as u32 + 1; let trimmed = line.trim();
let is_indented = line.starts_with(" ") || line.starts_with("\t");
match language {
Language::Python => {
if trimmed.starts_with("class ") && !is_indented {
if let Some(name) = trimmed
.strip_prefix("class ")
.and_then(|s| s.split(['(', ':']).next())
{
current_class = Some(name.trim().to_string());
}
} else if !is_indented
&& !trimmed.is_empty()
&& !trimmed.starts_with("#")
&& !trimmed.starts_with("@")
{
if trimmed.starts_with("def ") || trimmed.starts_with("async def ") {
current_class = None;
} else if !trimmed.starts_with("class ") {
current_class = None;
}
}
if trimmed.starts_with("def test_") || trimmed.starts_with("async def test_") {
let func_start = if trimmed.starts_with("async ") {
"async def "
} else {
"def "
};
if let Some(name) = trimmed
.strip_prefix(func_start)
.and_then(|s| s.split('(').next())
{
functions.push(TestFunction {
file: file.to_path_buf(),
function: name.to_string(),
class: current_class.clone(),
line: line_num,
});
}
}
}
Language::TypeScript | Language::JavaScript => {
if trimmed.starts_with("test(") || trimmed.starts_with("it(") {
if let Some(start) = trimmed.find(['\'', '"']) {
let rest = &trimmed[start + 1..];
if let Some(end) = rest.find(['\'', '"']) {
functions.push(TestFunction {
file: file.to_path_buf(),
function: rest[..end].to_string(),
class: current_class.clone(),
line: line_num,
});
}
}
} else if trimmed.starts_with("describe(") {
if let Some(start) = trimmed.find(['\'', '"']) {
let rest = &trimmed[start + 1..];
if let Some(end) = rest.find(['\'', '"']) {
current_class = Some(rest[..end].to_string());
}
}
}
}
Language::Go => {
if trimmed.starts_with("func Test") {
if let Some(name) = trimmed
.strip_prefix("func ")
.and_then(|s| s.split('(').next())
{
functions.push(TestFunction {
file: file.to_path_buf(),
function: name.to_string(),
class: None,
line: line_num,
});
}
}
}
Language::Rust => {
if trimmed.starts_with("fn test_") || trimmed.starts_with("pub fn test_") {
let func_start = if trimmed.starts_with("pub fn ") {
"pub fn "
} else {
"fn "
};
if let Some(name) = trimmed
.strip_prefix(func_start)
.and_then(|s| s.split('(').next())
{
functions.push(TestFunction {
file: file.to_path_buf(),
function: name.to_string(),
class: None,
line: line_num,
});
}
}
}
_ => {
if trimmed.contains("test") && trimmed.contains("fn ") {
if let Some(fn_idx) = trimmed.find("fn ") {
let after_fn = &trimmed[fn_idx + 3..];
if let Some(name) = after_fn.split('(').next() {
functions.push(TestFunction {
file: file.to_path_buf(),
function: name.trim().to_string(),
class: None,
line: line_num,
});
}
}
}
}
}
}
functions
}
fn detect_git_changes_head(project: &Path) -> TldrResult<Vec<PathBuf>> {
let output = Command::new("git")
.args(["diff", "--name-only", "HEAD"])
.current_dir(project)
.output();
parse_git_diff_output(output, project)
}
fn detect_git_changes_base(project: &Path, base: &str) -> TldrResult<Vec<PathBuf>> {
let check_branch = Command::new("git")
.args(["rev-parse", "--verify", base])
.current_dir(project)
.output();
match check_branch {
Ok(output) if !output.status.success() => {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(crate::error::TldrError::InvalidArgs {
arg: "base".to_string(),
message: format!("Branch '{}' not found. {}", base, stderr.trim()),
suggestion: Some("Check branch name with: git branch -a".to_string()),
});
}
Err(e) => {
return Err(crate::error::TldrError::InvalidArgs {
arg: "git".to_string(),
message: format!("Git not available: {}", e),
suggestion: None,
});
}
_ => {}
}
let output = Command::new("git")
.args(["diff", "--name-only", &format!("{}...HEAD", base)])
.current_dir(project)
.output();
parse_git_diff_output(output, project)
}
fn detect_git_changes_staged(project: &Path) -> TldrResult<Vec<PathBuf>> {
let output = Command::new("git")
.args(["diff", "--name-only", "--staged"])
.current_dir(project)
.output();
parse_git_diff_output(output, project)
}
fn detect_git_changes_uncommitted(project: &Path) -> TldrResult<Vec<PathBuf>> {
let staged = Command::new("git")
.args(["diff", "--name-only", "--staged"])
.current_dir(project)
.output();
let unstaged = Command::new("git")
.args(["diff", "--name-only"])
.current_dir(project)
.output();
let mut files = HashSet::new();
if let Ok(output) = staged {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines().filter(|l| !l.is_empty()) {
let path = project.join(line);
if path.exists() {
files.insert(path);
}
}
}
}
if let Ok(output) = unstaged {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines().filter(|l| !l.is_empty()) {
let path = project.join(line);
if path.exists() {
files.insert(path);
}
}
}
}
Ok(files.into_iter().collect())
}
fn parse_git_diff_output(
output: std::io::Result<std::process::Output>,
project: &Path,
) -> TldrResult<Vec<PathBuf>> {
match output {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
let files: Vec<PathBuf> = stdout
.lines()
.filter(|line| !line.is_empty())
.map(|line| project.join(line))
.filter(|path| path.exists())
.collect();
Ok(files)
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(crate::error::TldrError::InvalidArgs {
arg: "git".to_string(),
message: format!("Git diff failed: {}", stderr.trim()),
suggestion: None,
})
}
Err(e) => Err(crate::error::TldrError::InvalidArgs {
arg: "git".to_string(),
message: format!("Git not available: {}", e),
suggestion: Some("Ensure git is installed and on your PATH".to_string()),
}),
}
}
fn find_functions_in_files(
call_graph: &ProjectCallGraph,
files: &[PathBuf],
project_root: &Path,
) -> HashSet<FunctionRef> {
let file_set: HashSet<&PathBuf> = files.iter().collect();
let mut functions = HashSet::new();
for edge in call_graph.edges() {
if file_set.contains(&edge.src_file) {
functions.insert(FunctionRef::new(
edge.src_file.clone(),
edge.src_func.clone(),
));
}
if file_set.contains(&edge.dst_file) {
functions.insert(FunctionRef::new(
edge.dst_file.clone(),
edge.dst_func.clone(),
));
}
}
for file in files {
let absolute_path = if file.is_absolute() {
file.clone()
} else {
project_root.join(file)
};
match crate::ast::extract_file(&absolute_path, Some(project_root)) {
Ok(module_info) => {
for func in &module_info.functions {
functions.insert(FunctionRef::new(file.clone(), func.name.clone()));
}
for class in &module_info.classes {
for method in &class.methods {
let qualified_name = format!("{}.{}", class.name, method.name);
functions.insert(FunctionRef::new(file.clone(), qualified_name));
}
}
}
Err(e) => {
eprintln!(
"Warning: AST extraction failed for {}: {}",
absolute_path.display(),
e
);
}
}
}
functions
}
fn find_affected_functions_with_depth(
call_graph: &ProjectCallGraph,
changed_functions: &HashSet<FunctionRef>,
max_depth: usize,
) -> Vec<FunctionRef> {
let mut affected = HashSet::new();
let mut to_visit: Vec<(FunctionRef, usize)> =
changed_functions.iter().map(|f| (f.clone(), 0)).collect();
let mut visited: HashSet<FunctionRef> = HashSet::new();
let reverse_graph = build_reverse_call_graph(call_graph);
while let Some((func, depth)) = to_visit.pop() {
if visited.contains(&func) {
continue;
}
visited.insert(func.clone());
affected.insert(func.clone());
if depth >= max_depth {
continue;
}
if let Some(callers) = reverse_graph.get(&func) {
for caller in callers {
if !visited.contains(caller) {
to_visit.push((caller.clone(), depth + 1));
}
}
}
}
affected.into_iter().collect()
}
fn build_reverse_call_graph(
call_graph: &ProjectCallGraph,
) -> std::collections::HashMap<FunctionRef, Vec<FunctionRef>> {
let mut reverse = std::collections::HashMap::new();
for edge in call_graph.edges() {
let callee = FunctionRef::new(edge.dst_file.clone(), edge.dst_func.clone());
let caller = FunctionRef::new(edge.src_file.clone(), edge.src_func.clone());
reverse.entry(callee).or_insert_with(Vec::new).push(caller);
}
reverse
}
fn get_all_project_files(project: &Path, language: Language) -> TldrResult<Vec<PathBuf>> {
let extensions: HashSet<String> = language
.extensions()
.iter()
.map(|s| s.to_string())
.collect();
let tree = get_file_tree(
project,
Some(&extensions),
true,
Some(&IgnoreSpec::default()),
)?;
Ok(collect_files(&tree, project))
}
fn is_test_file(path: &Path, language: Language) -> bool {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let path_str = path.to_string_lossy();
let in_tests_dir = || {
path_str.contains("/tests/")
|| path_str.starts_with("tests/")
|| path_str.contains("/test/")
|| path_str.starts_with("test/")
};
let in_dunder_tests = || path_str.contains("/__tests__/") || path_str.starts_with("__tests__/");
match language {
Language::Python => {
file_name.starts_with("test_")
|| file_name.ends_with("_test.py")
|| file_name == "conftest.py"
|| in_tests_dir()
}
Language::TypeScript | Language::JavaScript => {
file_name.ends_with(".test.ts")
|| file_name.ends_with(".test.js")
|| file_name.ends_with(".spec.ts")
|| file_name.ends_with(".spec.js")
|| file_name.ends_with(".test.tsx")
|| file_name.ends_with(".test.jsx")
|| in_dunder_tests()
}
Language::Go => file_name.ends_with("_test.go"),
Language::Rust => in_tests_dir() || file_name == "tests.rs",
_ => {
file_name.contains("test") || in_tests_dir()
}
}
}
fn find_affected_tests(
test_files: &HashSet<PathBuf>,
changed_files: &[PathBuf],
affected_functions: &[FunctionRef],
call_graph: &ProjectCallGraph,
) -> Vec<PathBuf> {
let mut affected_tests = HashSet::new();
for file in changed_files {
if test_files.contains(file) {
affected_tests.insert(file.clone());
}
}
let affected_files: HashSet<&PathBuf> = affected_functions.iter().map(|f| &f.file).collect();
for test_file in test_files {
if affected_files.contains(test_file) {
affected_tests.insert(test_file.clone());
}
}
let changed_file_set: HashSet<&PathBuf> = changed_files.iter().collect();
for edge in call_graph.edges() {
if test_files.contains(&edge.src_file) && changed_file_set.contains(&edge.dst_file) {
affected_tests.insert(edge.src_file.clone());
}
}
let mut result: Vec<PathBuf> = affected_tests.into_iter().collect();
result.sort();
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_test_file_python() {
assert!(is_test_file(Path::new("test_main.py"), Language::Python));
assert!(is_test_file(Path::new("main_test.py"), Language::Python));
assert!(is_test_file(Path::new("conftest.py"), Language::Python));
assert!(is_test_file(
Path::new("tests/test_utils.py"),
Language::Python
));
assert!(!is_test_file(Path::new("main.py"), Language::Python));
}
#[test]
fn test_is_test_file_typescript() {
assert!(is_test_file(
Path::new("main.test.ts"),
Language::TypeScript
));
assert!(is_test_file(
Path::new("main.spec.ts"),
Language::TypeScript
));
assert!(is_test_file(
Path::new("__tests__/main.ts"),
Language::TypeScript
));
assert!(!is_test_file(Path::new("main.ts"), Language::TypeScript));
}
#[test]
fn test_is_test_file_go() {
assert!(is_test_file(Path::new("main_test.go"), Language::Go));
assert!(!is_test_file(Path::new("main.go"), Language::Go));
}
#[test]
fn test_is_test_file_rust() {
assert!(is_test_file(
Path::new("tests/integration.rs"),
Language::Rust
));
assert!(is_test_file(Path::new("src/lib/tests.rs"), Language::Rust));
assert!(!is_test_file(Path::new("src/main.rs"), Language::Rust));
}
#[test]
fn test_detection_method_display() {
assert_eq!(DetectionMethod::GitHead.to_string(), "git:HEAD");
assert_eq!(
DetectionMethod::GitBase {
base: "main".to_string()
}
.to_string(),
"git:main...HEAD"
);
assert_eq!(DetectionMethod::GitStaged.to_string(), "git:staged");
assert_eq!(
DetectionMethod::GitUncommitted.to_string(),
"git:uncommitted"
);
assert_eq!(DetectionMethod::Session.to_string(), "session");
assert_eq!(DetectionMethod::Explicit.to_string(), "explicit");
}
#[test]
fn test_empty_change_impact() {
let report = ChangeImpactReport {
changed_files: vec![],
affected_tests: vec![],
affected_test_functions: vec![],
affected_functions: vec![],
detection_method: "explicit".to_string(),
metadata: None,
};
assert!(report.changed_files.is_empty());
assert!(report.affected_tests.is_empty());
}
#[test]
fn test_extract_python_test_functions() {
let content = r#"
class TestAuth:
def test_login(self):
pass
def test_logout(self):
pass
def test_standalone():
pass
"#;
let file = Path::new("test_auth.py");
let functions = extract_test_functions_from_content(file, content, Language::Python);
assert_eq!(functions.len(), 3);
assert!(functions
.iter()
.any(|f| f.function == "test_login" && f.class == Some("TestAuth".to_string())));
assert!(functions
.iter()
.any(|f| f.function == "test_logout" && f.class == Some("TestAuth".to_string())));
assert!(functions
.iter()
.any(|f| f.function == "test_standalone" && f.class.is_none()));
}
#[test]
fn test_find_functions_in_files_includes_standalone() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let src = project.join("src");
std::fs::create_dir_all(&src).unwrap();
let module_path = src.join("module.py");
std::fs::write(
&module_path,
r#"
def connected_caller():
return connected_callee()
def connected_callee():
return 42
def standalone_func():
"""This function neither calls nor is called by anything."""
return "I exist but am isolated"
"#,
)
.unwrap();
let call_graph = build_project_call_graph(project, Language::Python, None, true).unwrap();
let changed_files = vec![PathBuf::from("src/module.py")];
let functions = find_functions_in_files(&call_graph, &changed_files, project);
let names: HashSet<&str> = functions.iter().map(|f| f.name.as_str()).collect();
assert!(
names.contains("connected_caller"),
"Should find connected_caller (it appears in call edges as source)"
);
assert!(
names.contains("connected_callee"),
"Should find connected_callee (it appears in call edges as destination)"
);
assert!(
names.contains("standalone_func"),
"Should find standalone_func even though it has no call edges. \
Found only: {:?}",
names
);
}
#[test]
fn test_find_functions_in_files_includes_standalone_methods() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let src = project.join("src");
std::fs::create_dir_all(&src).unwrap();
let module_path = src.join("myclass.py");
std::fs::write(
&module_path,
r#"
class MyClass:
def used_method(self):
return self.helper()
def helper(self):
return 42
def orphan_method(self):
"""Not called by anything, does not call anything."""
return "orphan"
"#,
)
.unwrap();
let call_graph = build_project_call_graph(project, Language::Python, None, true).unwrap();
let changed_files = vec![PathBuf::from("src/myclass.py")];
let functions = find_functions_in_files(&call_graph, &changed_files, project);
let names: HashSet<&str> = functions.iter().map(|f| f.name.as_str()).collect();
assert!(
names.contains("orphan_method") || names.contains("MyClass.orphan_method"),
"Should find orphan_method even though it has no call edges. Found: {:?}",
names
);
}
#[test]
fn test_extract_go_test_functions() {
let content = r#"
package auth
func TestLogin(t *testing.T) {
// test
}
func TestLogout(t *testing.T) {
// test
}
"#;
let file = Path::new("auth_test.go");
let functions = extract_test_functions_from_content(file, content, Language::Go);
assert_eq!(functions.len(), 2);
assert!(functions.iter().any(|f| f.function == "TestLogin"));
assert!(functions.iter().any(|f| f.function == "TestLogout"));
}
}