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, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "details")]
pub enum ChangeImpactStatus {
#[default]
Completed,
NoChanges,
NoBaseline {
reason: String,
},
DetectionFailed {
reason: String,
},
}
#[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>,
#[serde(default)]
pub status: ChangeImpactStatus,
}
#[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: Vec<PathBuf> = match &method {
DetectionMethod::Explicit => {
match explicit_files {
Some(ref f) if !f.is_empty() => f.clone(),
_ => {
return Ok(make_status_report(
method.clone(),
language,
depth,
ChangeImpactStatus::NoBaseline {
reason: "--files flag passed with no paths".to_string(),
},
));
}
}
}
DetectionMethod::GitHead => match detect_git_changes_head(project) {
Ok(files) => files,
Err(e) => {
return Ok(make_status_report(
method.clone(),
language,
depth,
classify_detection_error(&e),
));
}
},
DetectionMethod::GitBase { base } => match detect_git_changes_base(project, base) {
Ok(files) => files,
Err(e) => {
return Ok(make_status_report(
method.clone(),
language,
depth,
classify_detection_error(&e),
));
}
},
DetectionMethod::GitStaged => match detect_git_changes_staged(project) {
Ok(files) => files,
Err(e) => {
return Ok(make_status_report(
method.clone(),
language,
depth,
classify_detection_error(&e),
));
}
},
DetectionMethod::GitUncommitted => match detect_git_changes_uncommitted(project) {
Ok(files) => files,
Err(e) => {
return Ok(make_status_report(
method.clone(),
language,
depth,
classify_detection_error(&e),
));
}
},
DetectionMethod::Session => {
vec![]
}
};
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() {
let status = match &method {
DetectionMethod::Session => ChangeImpactStatus::Completed,
_ => ChangeImpactStatus::NoChanges,
};
return Ok(make_status_report(method, language, depth, status));
}
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: 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),
})
},
status: ChangeImpactStatus::Completed,
})
}
fn make_status_report(
method: DetectionMethod,
language: Language,
depth: usize,
status: ChangeImpactStatus,
) -> ChangeImpactReport {
ChangeImpactReport {
changed_files: vec![],
affected_tests: vec![],
affected_test_functions: vec![],
affected_functions: vec![],
detection_method: method.to_string(),
metadata: Some(ChangeImpactMetadata {
language: language.to_string(),
call_graph_nodes: 0,
call_graph_edges: 0,
analysis_depth: Some(depth),
}),
status,
}
}
fn classify_detection_error(err: &crate::error::TldrError) -> ChangeImpactStatus {
let msg = err.to_string();
let lower = msg.to_lowercase();
let baseline_missing = lower.contains("git not available")
|| lower.contains("not a git repository")
|| lower.contains("does not have any commits")
|| lower.contains("does not exist")
|| lower.contains("no such file or directory")
|| lower.contains("unknown revision head")
|| lower.contains("needed a single revision");
if baseline_missing {
ChangeImpactStatus::NoBaseline { reason: msg }
} else {
ChangeImpactStatus::DetectionFailed { reason: msg }
}
}
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,
status: ChangeImpactStatus::NoChanges,
};
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"));
}
fn init_git_repo_with_one_commit(dir: &Path) {
let run = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("git should be available in the test environment");
assert!(
out.status.success(),
"git {:?} failed: stdout={} stderr={}",
args,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
};
run(&["init", "-q"]);
run(&["config", "user.email", "test@test.com"]);
run(&["config", "user.name", "Test"]);
run(&["config", "commit.gpgsign", "false"]);
std::fs::write(dir.join("README.md"), "# seed\n").unwrap();
run(&["add", "README.md"]);
run(&["commit", "-q", "-m", "seed"]);
}
#[test]
fn test_status_completed_on_real_changes() {
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 file_path = src.join("module.py");
std::fs::write(&file_path, "def f():\n return 1\n").unwrap();
let report = change_impact_extended(
project,
DetectionMethod::Explicit,
Language::Python,
10,
true,
&[],
Some(vec![file_path.clone()]),
)
.expect("change_impact_extended should not error on explicit files");
assert_eq!(
report.status,
ChangeImpactStatus::Completed,
"explicit with existing python file should be Completed, got {:?}",
report.status
);
assert!(
!report.changed_files.is_empty(),
"changed_files should be non-empty for a real file"
);
}
#[test]
fn test_status_no_changes_on_clean_githead() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let project = tmp.path();
init_git_repo_with_one_commit(project);
let report = change_impact_extended(
project,
DetectionMethod::GitHead,
Language::Python,
10,
true,
&[],
None,
)
.expect("GitHead on clean tree should return Ok");
assert_eq!(
report.status,
ChangeImpactStatus::NoChanges,
"clean-tree GitHead should be NoChanges, got {:?}",
report.status
);
assert!(report.changed_files.is_empty());
assert_eq!(report.detection_method, "git:HEAD");
}
#[test]
fn test_status_no_baseline_when_not_git() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let report = change_impact_extended(
project,
DetectionMethod::GitHead,
Language::Python,
10,
true,
&[],
None,
)
.expect("GitHead on non-git dir should return Ok(report) with status");
match &report.status {
ChangeImpactStatus::NoBaseline { reason } => {
let lower = reason.to_lowercase();
assert!(
lower.contains("git")
|| lower.contains("repository")
|| lower.contains("baseline"),
"NoBaseline reason should mention git/repository, got: {}",
reason
);
}
other => panic!("expected NoBaseline for non-git dir, got {:?}", other),
}
assert!(report.changed_files.is_empty());
}
#[test]
fn test_status_detection_failed_on_bad_base() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let project = tmp.path();
init_git_repo_with_one_commit(project);
let result = change_impact_extended(
project,
DetectionMethod::GitBase {
base: "nonexistent-branch-xyz".to_string(),
},
Language::Python,
10,
true,
&[],
None,
);
match result {
Ok(report) => match &report.status {
ChangeImpactStatus::DetectionFailed { reason }
| ChangeImpactStatus::NoBaseline { reason } => {
let lower = reason.to_lowercase();
assert!(
lower.contains("not found")
|| lower.contains("branch")
|| lower.contains("unknown")
|| lower.contains("nonexistent"),
"reason should mention the bad branch, got: {}",
reason
);
}
other => panic!(
"expected DetectionFailed/NoBaseline for bogus base, got {:?}",
other
),
},
Err(e) => {
let msg = e.to_string().to_lowercase();
assert!(
msg.contains("not found") || msg.contains("branch") || msg.contains("unknown"),
"error should mention the bad branch, got: {}",
e
);
}
}
}
#[test]
fn test_status_completed_session_mode() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let report = change_impact_extended(
project,
DetectionMethod::Session,
Language::Python,
10,
true,
&[],
None,
)
.expect("Session mode should not error");
assert_eq!(
report.status,
ChangeImpactStatus::Completed,
"Session mode should be Completed even with no files, got {:?}",
report.status
);
assert!(report.changed_files.is_empty());
}
#[test]
fn test_status_explicit_empty_is_no_baseline() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let report = change_impact_extended(
project,
DetectionMethod::Explicit,
Language::Python,
10,
true,
&[],
None,
)
.expect("Explicit-empty should return Ok(report) with NoBaseline");
match &report.status {
ChangeImpactStatus::NoBaseline { reason } => {
let lower = reason.to_lowercase();
assert!(
lower.contains("files") || lower.contains("paths") || lower.contains("no"),
"reason should mention no files/paths supplied, got: {}",
reason
);
}
other => panic!("expected NoBaseline for explicit-empty, got {:?}", other),
}
}
#[test]
fn test_status_explicit_empty_vec_is_no_baseline() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let report = change_impact_extended(
project,
DetectionMethod::Explicit,
Language::Python,
10,
true,
&[],
Some(vec![]),
)
.expect("Explicit with empty Vec should return Ok(report) with NoBaseline");
match &report.status {
ChangeImpactStatus::NoBaseline { .. } => {}
other => panic!(
"expected NoBaseline for explicit empty Vec, got {:?}",
other
),
}
}
#[test]
fn test_status_deserializes_with_default_for_legacy_json() {
let legacy_json = r#"{
"changed_files": [],
"affected_tests": [],
"affected_functions": [],
"detection_method": "explicit"
}"#;
let report: ChangeImpactReport =
serde_json::from_str(legacy_json).expect("legacy JSON should deserialize");
assert_eq!(report.status, ChangeImpactStatus::Completed);
}
}