Skip to main content

tldr_core/analysis/
change_impact.rs

1//! Change impact analysis (spec Section 2.7.2)
2//!
3//! Find tests affected by changed files to enable selective test execution.
4//!
5//! # Algorithm
6//! 1. Detect changed files (git diff, session-modified, or explicit list)
7//! 2. Build call graph and import graph for the project
8//! 3. Find functions defined in changed files
9//! 4. Use call graph to find functions that call changed functions
10//! 5. Use import graph to find modules that import changed modules
11//! 6. Filter to test files using language-specific patterns
12//!
13//! # Test File Detection Patterns
14//! - Python: `test_*.py`, `*_test.py`, `conftest.py`
15//! - TypeScript/JavaScript: `*.test.{js,ts}`, `*.spec.{js,ts}`
16//! - Go: `*_test.go`
17//! - Rust: `tests/*.rs`, `src/**/tests.rs`
18
19use std::collections::HashSet;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22
23use serde::{Deserialize, Serialize};
24
25use crate::callgraph::build_project_call_graph;
26use crate::fs::tree::{collect_files, get_file_tree};
27use crate::types::{FunctionRef, IgnoreSpec, Language, ProjectCallGraph};
28use crate::TldrResult;
29
30/// Change impact analysis report
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ChangeImpactReport {
33    /// Files that were detected as changed
34    pub changed_files: Vec<PathBuf>,
35    /// Test files that may be affected by the changes
36    pub affected_tests: Vec<PathBuf>,
37    /// Test functions affected (function-level granularity)
38    #[serde(default)]
39    pub affected_test_functions: Vec<TestFunction>,
40    /// Functions affected by the changes (transitively)
41    pub affected_functions: Vec<FunctionRef>,
42    /// How changes were detected: "git:HEAD", "git:staged", etc.
43    pub detection_method: String,
44    /// Analysis metadata
45    #[serde(default)]
46    pub metadata: Option<ChangeImpactMetadata>,
47}
48
49/// Individual test function with location information
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct TestFunction {
52    /// File containing the test
53    pub file: PathBuf,
54    /// Function name
55    pub function: String,
56    /// Class name for class-based test methods (e.g., TestAuth)
57    pub class: Option<String>,
58    /// Line number (1-indexed)
59    pub line: u32,
60}
61
62/// Metadata about the analysis
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct ChangeImpactMetadata {
65    /// Programming language analyzed
66    pub language: String,
67    /// Number of nodes in the call graph
68    pub call_graph_nodes: usize,
69    /// Number of edges in the call graph
70    pub call_graph_edges: usize,
71    /// Maximum traversal depth used
72    pub analysis_depth: Option<usize>,
73}
74
75/// Detect method for finding changed files
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum DetectionMethod {
78    /// git diff HEAD (default)
79    GitHead,
80    /// git diff <base>...HEAD (PR workflow)
81    GitBase {
82        /// Base ref/branch used for the three-dot comparison.
83        base: String,
84    },
85    /// git diff --staged (pre-commit)
86    GitStaged,
87    /// git diff (uncommitted: staged + unstaged)
88    GitUncommitted,
89    /// Explicit list provided by caller
90    Explicit,
91    /// Session tracking (placeholder - would need session tracking)
92    Session,
93}
94
95impl std::fmt::Display for DetectionMethod {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            DetectionMethod::GitHead => write!(f, "git:HEAD"),
99            DetectionMethod::GitBase { base } => write!(f, "git:{}...HEAD", base),
100            DetectionMethod::GitStaged => write!(f, "git:staged"),
101            DetectionMethod::GitUncommitted => write!(f, "git:uncommitted"),
102            DetectionMethod::Explicit => write!(f, "explicit"),
103            DetectionMethod::Session => write!(f, "session"),
104        }
105    }
106}
107
108/// Find tests affected by changed files.
109///
110/// # Arguments
111/// * `project` - Project root directory
112/// * `changed_files` - Optional explicit list of changed files. If None, uses git diff.
113/// * `language` - Programming language
114///
115/// # Returns
116/// * `Ok(ChangeImpactReport)` - Report of affected tests and functions
117///
118/// # Example
119/// ```ignore
120/// let report = change_impact(
121///     Path::new("src"),
122///     None,  // auto-detect via git
123///     Language::Python,
124/// )?;
125///
126/// for test in &report.affected_tests {
127///     println!("Run: {}", test.display());
128/// }
129/// ```
130pub fn change_impact(
131    project: &Path,
132    changed_files: Option<&[PathBuf]>,
133    language: Language,
134) -> TldrResult<ChangeImpactReport> {
135    // Determine detection method based on whether explicit files are provided
136    let (method, explicit) = if let Some(files) = changed_files {
137        if files.is_empty() {
138            // Empty list = use GitHead but no explicit files
139            (DetectionMethod::GitHead, None)
140        } else {
141            // Non-empty list = use Explicit with files
142            (DetectionMethod::Explicit, Some(files.to_vec()))
143        }
144    } else {
145        // None = use GitHead auto-detection
146        (DetectionMethod::GitHead, None)
147    };
148
149    change_impact_extended(
150        project,
151        method,
152        language,
153        10,   // default depth
154        true, // include imports
155        &[],  // no custom test patterns
156        explicit,
157    )
158}
159
160/// Extended change impact analysis with configurable detection method and options.
161///
162/// # Arguments
163/// * `project` - Project root directory
164/// * `method` - How to detect changed files
165/// * `language` - Programming language
166/// * `depth` - Maximum call graph traversal depth
167/// * `include_imports` - Whether to include import graph in analysis
168/// * `test_patterns` - Custom test file patterns (overrides defaults if non-empty)
169/// * `explicit_files` - Optional explicit list (used with DetectionMethod::Explicit)
170///
171/// # Returns
172/// * `Ok(ChangeImpactReport)` - Report of affected tests and functions
173pub fn change_impact_extended(
174    project: &Path,
175    method: DetectionMethod,
176    language: Language,
177    depth: usize,
178    _include_imports: bool,    // TODO: Use this in Phase 3
179    _test_patterns: &[String], // TODO: Use this in Phase 3
180    explicit_files: Option<Vec<PathBuf>>,
181) -> TldrResult<ChangeImpactReport> {
182    // Step 1: Determine changed files based on detection method
183    let (files, actual_method) = match &method {
184        DetectionMethod::Explicit => {
185            let files = explicit_files.unwrap_or_default();
186            (files, method.clone())
187        }
188        DetectionMethod::GitHead => {
189            match detect_git_changes_head(project) {
190                Ok(files) if !files.is_empty() => (files, method.clone()),
191                Ok(_) => (vec![], method.clone()), // No changes is valid
192                Err(_) => (vec![], DetectionMethod::Session), // Git not available
193            }
194        }
195        DetectionMethod::GitBase { base } => {
196            match detect_git_changes_base(project, base) {
197                Ok(files) => (files, method.clone()),
198                Err(e) => {
199                    // Check if it's a branch not found error
200                    let err_str = e.to_string();
201                    if err_str.contains("not found") || err_str.contains("unknown revision") {
202                        return Err(e);
203                    }
204                    (vec![], DetectionMethod::Session)
205                }
206            }
207        }
208        DetectionMethod::GitStaged => match detect_git_changes_staged(project) {
209            Ok(files) => (files, method.clone()),
210            Err(_) => (vec![], DetectionMethod::Session),
211        },
212        DetectionMethod::GitUncommitted => match detect_git_changes_uncommitted(project) {
213            Ok(files) => (files, method.clone()),
214            Err(_) => (vec![], DetectionMethod::Session),
215        },
216        DetectionMethod::Session => (vec![], method.clone()),
217    };
218
219    // Filter to only files matching the target language
220    let changed_files: Vec<PathBuf> = files
221        .into_iter()
222        .filter(|f| {
223            f.extension()
224                .and_then(|ext| ext.to_str())
225                .map(|ext| Language::from_extension(ext) == Some(language))
226                .unwrap_or(false)
227        })
228        .collect();
229
230    // If no changed files, return empty report
231    if changed_files.is_empty() {
232        return Ok(ChangeImpactReport {
233            changed_files: vec![],
234            affected_tests: vec![],
235            affected_test_functions: vec![],
236            affected_functions: vec![],
237            detection_method: actual_method.to_string(),
238            metadata: Some(ChangeImpactMetadata {
239                language: language.to_string(),
240                call_graph_nodes: 0,
241                call_graph_edges: 0,
242                analysis_depth: Some(depth),
243            }),
244        });
245    }
246
247    // Step 2: Build call graph
248    let call_graph = build_project_call_graph(project, language, None, true)?;
249
250    // Step 3: Find functions in changed files (call graph edges + AST extraction)
251    let changed_functions = find_functions_in_files(&call_graph, &changed_files, project);
252
253    // Step 4: Find all affected functions (callers of changed functions) with depth limit
254    let affected_functions =
255        find_affected_functions_with_depth(&call_graph, &changed_functions, depth);
256
257    // Step 5: Find all project files
258    let all_files = get_all_project_files(project, language)?;
259
260    // Step 6: Filter to test files
261    let test_files: HashSet<PathBuf> = all_files
262        .iter()
263        .filter(|f| is_test_file(f, language))
264        .cloned()
265        .collect();
266
267    // Step 7: Find affected tests
268    // A test is affected if:
269    // - It's in a changed file
270    // - It imports a changed module
271    // - It calls a changed function
272    let affected_tests = find_affected_tests(
273        &test_files,
274        &changed_files,
275        &affected_functions,
276        &call_graph,
277    );
278
279    // Extract test functions from affected test files (Phase 4)
280    let affected_test_functions = extract_test_functions_from_files(&affected_tests, language);
281
282    Ok(ChangeImpactReport {
283        changed_files,
284        affected_tests,
285        affected_test_functions,
286        affected_functions,
287        detection_method: actual_method.to_string(),
288        metadata: {
289            let edge_count = call_graph.edges().count();
290            Some(ChangeImpactMetadata {
291                language: language.to_string(),
292                call_graph_nodes: edge_count, // Approximate using edge count
293                call_graph_edges: edge_count,
294                analysis_depth: Some(depth),
295            })
296        },
297    })
298}
299
300/// Extract test functions from a list of test files
301fn extract_test_functions_from_files(
302    test_files: &[PathBuf],
303    language: Language,
304) -> Vec<TestFunction> {
305    let mut test_functions = Vec::new();
306
307    for file in test_files {
308        if let Ok(content) = std::fs::read_to_string(file) {
309            test_functions.extend(extract_test_functions_from_content(
310                file, &content, language,
311            ));
312        }
313    }
314
315    test_functions
316}
317
318/// Extract test functions from file content based on language patterns
319fn extract_test_functions_from_content(
320    file: &Path,
321    content: &str,
322    language: Language,
323) -> Vec<TestFunction> {
324    let mut functions = Vec::new();
325    let mut current_class: Option<String> = None;
326
327    for (line_num, line) in content.lines().enumerate() {
328        let line_num = line_num as u32 + 1; // 1-indexed
329        let trimmed = line.trim();
330        let is_indented = line.starts_with("    ") || line.starts_with("\t");
331
332        match language {
333            Language::Python => {
334                // Track class context
335                if trimmed.starts_with("class ") && !is_indented {
336                    // Extract class name: "class TestAuth:" -> "TestAuth"
337                    if let Some(name) = trimmed
338                        .strip_prefix("class ")
339                        .and_then(|s| s.split(['(', ':']).next())
340                    {
341                        current_class = Some(name.trim().to_string());
342                    }
343                } else if !is_indented
344                    && !trimmed.is_empty()
345                    && !trimmed.starts_with("#")
346                    && !trimmed.starts_with("@")
347                {
348                    // Non-indented, non-empty line - we're at module level
349                    // Top-level def or any other statement means we're outside a class
350                    if trimmed.starts_with("def ") || trimmed.starts_with("async def ") {
351                        // Top-level function definition - clear class context
352                        current_class = None;
353                    } else if !trimmed.starts_with("class ") {
354                        // Other module-level statement - clear class context
355                        current_class = None;
356                    }
357                }
358
359                // Look for test functions
360                if trimmed.starts_with("def test_") || trimmed.starts_with("async def test_") {
361                    let func_start = if trimmed.starts_with("async ") {
362                        "async def "
363                    } else {
364                        "def "
365                    };
366                    if let Some(name) = trimmed
367                        .strip_prefix(func_start)
368                        .and_then(|s| s.split('(').next())
369                    {
370                        functions.push(TestFunction {
371                            file: file.to_path_buf(),
372                            function: name.to_string(),
373                            class: current_class.clone(),
374                            line: line_num,
375                        });
376                    }
377                }
378            }
379            Language::TypeScript | Language::JavaScript => {
380                // Look for test(), it(), describe()
381                if trimmed.starts_with("test(") || trimmed.starts_with("it(") {
382                    // Extract test name from: test('name', or it('name',
383                    if let Some(start) = trimmed.find(['\'', '"']) {
384                        let rest = &trimmed[start + 1..];
385                        if let Some(end) = rest.find(['\'', '"']) {
386                            functions.push(TestFunction {
387                                file: file.to_path_buf(),
388                                function: rest[..end].to_string(),
389                                class: current_class.clone(),
390                                line: line_num,
391                            });
392                        }
393                    }
394                } else if trimmed.starts_with("describe(") {
395                    // Track describe block as "class"
396                    if let Some(start) = trimmed.find(['\'', '"']) {
397                        let rest = &trimmed[start + 1..];
398                        if let Some(end) = rest.find(['\'', '"']) {
399                            current_class = Some(rest[..end].to_string());
400                        }
401                    }
402                }
403            }
404            Language::Go => {
405                // Look for func Test...
406                if trimmed.starts_with("func Test") {
407                    if let Some(name) = trimmed
408                        .strip_prefix("func ")
409                        .and_then(|s| s.split('(').next())
410                    {
411                        functions.push(TestFunction {
412                            file: file.to_path_buf(),
413                            function: name.to_string(),
414                            class: None,
415                            line: line_num,
416                        });
417                    }
418                }
419            }
420            Language::Rust => {
421                // Look for #[test] followed by fn
422                // This is a simplified check - proper parsing would track #[test] attributes
423                if trimmed.starts_with("fn test_") || trimmed.starts_with("pub fn test_") {
424                    let func_start = if trimmed.starts_with("pub fn ") {
425                        "pub fn "
426                    } else {
427                        "fn "
428                    };
429                    if let Some(name) = trimmed
430                        .strip_prefix(func_start)
431                        .and_then(|s| s.split('(').next())
432                    {
433                        functions.push(TestFunction {
434                            file: file.to_path_buf(),
435                            function: name.to_string(),
436                            class: None,
437                            line: line_num,
438                        });
439                    }
440                }
441            }
442            _ => {
443                // Generic test detection
444                if trimmed.contains("test") && trimmed.contains("fn ") {
445                    // Try to extract function name
446                    if let Some(fn_idx) = trimmed.find("fn ") {
447                        let after_fn = &trimmed[fn_idx + 3..];
448                        if let Some(name) = after_fn.split('(').next() {
449                            functions.push(TestFunction {
450                                file: file.to_path_buf(),
451                                function: name.trim().to_string(),
452                                class: None,
453                                line: line_num,
454                            });
455                        }
456                    }
457                }
458            }
459        }
460    }
461
462    functions
463}
464
465/// Detect changed files using git diff HEAD (uncommitted changes vs HEAD)
466fn detect_git_changes_head(project: &Path) -> TldrResult<Vec<PathBuf>> {
467    let output = Command::new("git")
468        .args(["diff", "--name-only", "HEAD"])
469        .current_dir(project)
470        .output();
471
472    parse_git_diff_output(output, project)
473}
474
475/// Detect changed files using git diff against a base branch (PR workflow)
476/// Uses merge-base to find common ancestor: git diff $(git merge-base base HEAD)...HEAD
477fn detect_git_changes_base(project: &Path, base: &str) -> TldrResult<Vec<PathBuf>> {
478    // First, verify the base branch exists
479    let check_branch = Command::new("git")
480        .args(["rev-parse", "--verify", base])
481        .current_dir(project)
482        .output();
483
484    match check_branch {
485        Ok(output) if !output.status.success() => {
486            let stderr = String::from_utf8_lossy(&output.stderr);
487            return Err(crate::error::TldrError::InvalidArgs {
488                arg: "base".to_string(),
489                message: format!("Branch '{}' not found. {}", base, stderr.trim()),
490                suggestion: Some("Check branch name with: git branch -a".to_string()),
491            });
492        }
493        Err(e) => {
494            return Err(crate::error::TldrError::InvalidArgs {
495                arg: "git".to_string(),
496                message: format!("Git not available: {}", e),
497                suggestion: None,
498            });
499        }
500        _ => {}
501    }
502
503    // Use the three-dot syntax for comparing branches
504    let output = Command::new("git")
505        .args(["diff", "--name-only", &format!("{}...HEAD", base)])
506        .current_dir(project)
507        .output();
508
509    parse_git_diff_output(output, project)
510}
511
512/// Detect only staged files (pre-commit workflow)
513fn detect_git_changes_staged(project: &Path) -> TldrResult<Vec<PathBuf>> {
514    let output = Command::new("git")
515        .args(["diff", "--name-only", "--staged"])
516        .current_dir(project)
517        .output();
518
519    parse_git_diff_output(output, project)
520}
521
522/// Detect all uncommitted changes (staged + unstaged)
523fn detect_git_changes_uncommitted(project: &Path) -> TldrResult<Vec<PathBuf>> {
524    // Get staged changes
525    let staged = Command::new("git")
526        .args(["diff", "--name-only", "--staged"])
527        .current_dir(project)
528        .output();
529
530    // Get unstaged changes
531    let unstaged = Command::new("git")
532        .args(["diff", "--name-only"])
533        .current_dir(project)
534        .output();
535
536    let mut files = HashSet::new();
537
538    if let Ok(output) = staged {
539        if output.status.success() {
540            let stdout = String::from_utf8_lossy(&output.stdout);
541            for line in stdout.lines().filter(|l| !l.is_empty()) {
542                let path = project.join(line);
543                if path.exists() {
544                    files.insert(path);
545                }
546            }
547        }
548    }
549
550    if let Ok(output) = unstaged {
551        if output.status.success() {
552            let stdout = String::from_utf8_lossy(&output.stdout);
553            for line in stdout.lines().filter(|l| !l.is_empty()) {
554                let path = project.join(line);
555                if path.exists() {
556                    files.insert(path);
557                }
558            }
559        }
560    }
561
562    Ok(files.into_iter().collect())
563}
564
565/// Parse git diff output into a list of file paths
566fn parse_git_diff_output(
567    output: std::io::Result<std::process::Output>,
568    project: &Path,
569) -> TldrResult<Vec<PathBuf>> {
570    match output {
571        Ok(output) if output.status.success() => {
572            let stdout = String::from_utf8_lossy(&output.stdout);
573            let files: Vec<PathBuf> = stdout
574                .lines()
575                .filter(|line| !line.is_empty())
576                .map(|line| project.join(line))
577                .filter(|path| path.exists())
578                .collect();
579            Ok(files)
580        }
581        Ok(output) => {
582            let stderr = String::from_utf8_lossy(&output.stderr);
583            Err(crate::error::TldrError::InvalidArgs {
584                arg: "git".to_string(),
585                message: format!("Git diff failed: {}", stderr.trim()),
586                suggestion: None,
587            })
588        }
589        Err(e) => Err(crate::error::TldrError::InvalidArgs {
590            arg: "git".to_string(),
591            message: format!("Git not available: {}", e),
592            suggestion: Some("Ensure git is installed and on your PATH".to_string()),
593        }),
594    }
595}
596
597/// Find functions defined in the given files.
598///
599/// Uses two passes to ensure completeness:
600/// 1. Call graph edges: finds functions that appear as sources or destinations in edges
601/// 2. AST extraction: finds ALL functions defined in the files, including standalone
602///    functions that neither call nor are called by anything
603///
604/// The AST pass is essential because the call graph only contains functions that
605/// participate in at least one call relationship. Functions with no callers and no
606/// callees (e.g., utility functions, dead code, newly added functions) would be
607/// completely invisible to a call-graph-only approach.
608fn find_functions_in_files(
609    call_graph: &ProjectCallGraph,
610    files: &[PathBuf],
611    project_root: &Path,
612) -> HashSet<FunctionRef> {
613    let file_set: HashSet<&PathBuf> = files.iter().collect();
614    let mut functions = HashSet::new();
615
616    // Pass 1: Functions that appear as sources or destinations in call edges
617    for edge in call_graph.edges() {
618        if file_set.contains(&edge.src_file) {
619            functions.insert(FunctionRef::new(
620                edge.src_file.clone(),
621                edge.src_func.clone(),
622            ));
623        }
624        if file_set.contains(&edge.dst_file) {
625            functions.insert(FunctionRef::new(
626                edge.dst_file.clone(),
627                edge.dst_func.clone(),
628            ));
629        }
630    }
631
632    // Pass 2: AST extraction to find ALL functions, including standalone ones
633    // that have no call graph edges at all
634    for file in files {
635        let absolute_path = if file.is_absolute() {
636            file.clone()
637        } else {
638            project_root.join(file)
639        };
640
641        match crate::ast::extract_file(&absolute_path, Some(project_root)) {
642            Ok(module_info) => {
643                // Add top-level functions
644                for func in &module_info.functions {
645                    functions.insert(FunctionRef::new(file.clone(), func.name.clone()));
646                }
647                // Add class methods (qualified as ClassName.method_name)
648                for class in &module_info.classes {
649                    for method in &class.methods {
650                        let qualified_name = format!("{}.{}", class.name, method.name);
651                        functions.insert(FunctionRef::new(file.clone(), qualified_name));
652                    }
653                }
654            }
655            Err(e) => {
656                // AST extraction can fail for various reasons (binary files,
657                // encoding issues, unsupported syntax). Log and continue --
658                // the call-graph pass already found what it could.
659                eprintln!(
660                    "Warning: AST extraction failed for {}: {}",
661                    absolute_path.display(),
662                    e
663                );
664            }
665        }
666    }
667
668    functions
669}
670
671/// Find all functions affected by changes with depth limiting
672fn find_affected_functions_with_depth(
673    call_graph: &ProjectCallGraph,
674    changed_functions: &HashSet<FunctionRef>,
675    max_depth: usize,
676) -> Vec<FunctionRef> {
677    let mut affected = HashSet::new();
678    // Track (function, current_depth)
679    let mut to_visit: Vec<(FunctionRef, usize)> =
680        changed_functions.iter().map(|f| (f.clone(), 0)).collect();
681    let mut visited: HashSet<FunctionRef> = HashSet::new();
682
683    // Build reverse graph for traversal
684    let reverse_graph = build_reverse_call_graph(call_graph);
685
686    while let Some((func, depth)) = to_visit.pop() {
687        if visited.contains(&func) {
688            continue;
689        }
690        visited.insert(func.clone());
691        affected.insert(func.clone());
692
693        // Stop traversing if we've reached max depth
694        if depth >= max_depth {
695            continue;
696        }
697
698        // Find all callers of this function
699        if let Some(callers) = reverse_graph.get(&func) {
700            for caller in callers {
701                if !visited.contains(caller) {
702                    to_visit.push((caller.clone(), depth + 1));
703                }
704            }
705        }
706    }
707
708    affected.into_iter().collect()
709}
710
711/// Build reverse call graph: callee -> [callers]
712fn build_reverse_call_graph(
713    call_graph: &ProjectCallGraph,
714) -> std::collections::HashMap<FunctionRef, Vec<FunctionRef>> {
715    let mut reverse = std::collections::HashMap::new();
716
717    for edge in call_graph.edges() {
718        let callee = FunctionRef::new(edge.dst_file.clone(), edge.dst_func.clone());
719        let caller = FunctionRef::new(edge.src_file.clone(), edge.src_func.clone());
720
721        reverse.entry(callee).or_insert_with(Vec::new).push(caller);
722    }
723
724    reverse
725}
726
727/// Get all source files in the project
728fn get_all_project_files(project: &Path, language: Language) -> TldrResult<Vec<PathBuf>> {
729    let extensions: HashSet<String> = language
730        .extensions()
731        .iter()
732        .map(|s| s.to_string())
733        .collect();
734
735    let tree = get_file_tree(
736        project,
737        Some(&extensions),
738        true,
739        Some(&IgnoreSpec::default()),
740    )?;
741    Ok(collect_files(&tree, project))
742}
743
744/// Check if a file is a test file based on language conventions
745fn is_test_file(path: &Path, language: Language) -> bool {
746    let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
747    let path_str = path.to_string_lossy();
748
749    // Helper to check if path contains a test directory
750    let in_tests_dir = || {
751        path_str.contains("/tests/")
752            || path_str.starts_with("tests/")
753            || path_str.contains("/test/")
754            || path_str.starts_with("test/")
755    };
756
757    let in_dunder_tests = || path_str.contains("/__tests__/") || path_str.starts_with("__tests__/");
758
759    match language {
760        Language::Python => {
761            file_name.starts_with("test_")
762                || file_name.ends_with("_test.py")
763                || file_name == "conftest.py"
764                || in_tests_dir()
765        }
766        Language::TypeScript | Language::JavaScript => {
767            file_name.ends_with(".test.ts")
768                || file_name.ends_with(".test.js")
769                || file_name.ends_with(".spec.ts")
770                || file_name.ends_with(".spec.js")
771                || file_name.ends_with(".test.tsx")
772                || file_name.ends_with(".test.jsx")
773                || in_dunder_tests()
774        }
775        Language::Go => file_name.ends_with("_test.go"),
776        Language::Rust => in_tests_dir() || file_name == "tests.rs",
777        _ => {
778            // Generic test detection
779            file_name.contains("test") || in_tests_dir()
780        }
781    }
782}
783
784/// Find test files affected by the changes
785fn find_affected_tests(
786    test_files: &HashSet<PathBuf>,
787    changed_files: &[PathBuf],
788    affected_functions: &[FunctionRef],
789    call_graph: &ProjectCallGraph,
790) -> Vec<PathBuf> {
791    let mut affected_tests = HashSet::new();
792
793    // 1. Test files that were directly changed
794    for file in changed_files {
795        if test_files.contains(file) {
796            affected_tests.insert(file.clone());
797        }
798    }
799
800    // 2. Test files that contain affected functions
801    let affected_files: HashSet<&PathBuf> = affected_functions.iter().map(|f| &f.file).collect();
802    for test_file in test_files {
803        if affected_files.contains(test_file) {
804            affected_tests.insert(test_file.clone());
805        }
806    }
807
808    // 3. Test files that call any changed function
809    let changed_file_set: HashSet<&PathBuf> = changed_files.iter().collect();
810    for edge in call_graph.edges() {
811        // If source is a test file and destination is in a changed file
812        if test_files.contains(&edge.src_file) && changed_file_set.contains(&edge.dst_file) {
813            affected_tests.insert(edge.src_file.clone());
814        }
815    }
816
817    let mut result: Vec<PathBuf> = affected_tests.into_iter().collect();
818    result.sort();
819    result
820}
821
822#[cfg(test)]
823mod tests {
824    use super::*;
825
826    #[test]
827    fn test_is_test_file_python() {
828        assert!(is_test_file(Path::new("test_main.py"), Language::Python));
829        assert!(is_test_file(Path::new("main_test.py"), Language::Python));
830        assert!(is_test_file(Path::new("conftest.py"), Language::Python));
831        assert!(is_test_file(
832            Path::new("tests/test_utils.py"),
833            Language::Python
834        ));
835        assert!(!is_test_file(Path::new("main.py"), Language::Python));
836    }
837
838    #[test]
839    fn test_is_test_file_typescript() {
840        assert!(is_test_file(
841            Path::new("main.test.ts"),
842            Language::TypeScript
843        ));
844        assert!(is_test_file(
845            Path::new("main.spec.ts"),
846            Language::TypeScript
847        ));
848        assert!(is_test_file(
849            Path::new("__tests__/main.ts"),
850            Language::TypeScript
851        ));
852        assert!(!is_test_file(Path::new("main.ts"), Language::TypeScript));
853    }
854
855    #[test]
856    fn test_is_test_file_go() {
857        assert!(is_test_file(Path::new("main_test.go"), Language::Go));
858        assert!(!is_test_file(Path::new("main.go"), Language::Go));
859    }
860
861    #[test]
862    fn test_is_test_file_rust() {
863        assert!(is_test_file(
864            Path::new("tests/integration.rs"),
865            Language::Rust
866        ));
867        assert!(is_test_file(Path::new("src/lib/tests.rs"), Language::Rust));
868        assert!(!is_test_file(Path::new("src/main.rs"), Language::Rust));
869    }
870
871    #[test]
872    fn test_detection_method_display() {
873        assert_eq!(DetectionMethod::GitHead.to_string(), "git:HEAD");
874        assert_eq!(
875            DetectionMethod::GitBase {
876                base: "main".to_string()
877            }
878            .to_string(),
879            "git:main...HEAD"
880        );
881        assert_eq!(DetectionMethod::GitStaged.to_string(), "git:staged");
882        assert_eq!(
883            DetectionMethod::GitUncommitted.to_string(),
884            "git:uncommitted"
885        );
886        assert_eq!(DetectionMethod::Session.to_string(), "session");
887        assert_eq!(DetectionMethod::Explicit.to_string(), "explicit");
888    }
889
890    #[test]
891    fn test_empty_change_impact() {
892        // With no changed files, should return empty report
893        let report = ChangeImpactReport {
894            changed_files: vec![],
895            affected_tests: vec![],
896            affected_test_functions: vec![],
897            affected_functions: vec![],
898            detection_method: "explicit".to_string(),
899            metadata: None,
900        };
901
902        assert!(report.changed_files.is_empty());
903        assert!(report.affected_tests.is_empty());
904    }
905
906    #[test]
907    fn test_extract_python_test_functions() {
908        let content = r#"
909class TestAuth:
910    def test_login(self):
911        pass
912
913    def test_logout(self):
914        pass
915
916def test_standalone():
917    pass
918"#;
919        let file = Path::new("test_auth.py");
920        let functions = extract_test_functions_from_content(file, content, Language::Python);
921
922        assert_eq!(functions.len(), 3);
923        assert!(functions
924            .iter()
925            .any(|f| f.function == "test_login" && f.class == Some("TestAuth".to_string())));
926        assert!(functions
927            .iter()
928            .any(|f| f.function == "test_logout" && f.class == Some("TestAuth".to_string())));
929        assert!(functions
930            .iter()
931            .any(|f| f.function == "test_standalone" && f.class.is_none()));
932    }
933
934    /// Test that find_functions_in_files discovers standalone functions
935    /// that do not appear in any call graph edge.
936    ///
937    /// Bug: Before the fix, find_functions_in_files only found functions
938    /// appearing as sources or destinations in call graph edges. Functions
939    /// that neither call nor are called by anything were completely missed.
940    #[test]
941    fn test_find_functions_in_files_includes_standalone() {
942        use tempfile::TempDir;
943
944        let tmp = TempDir::new().unwrap();
945        let project = tmp.path();
946
947        // Create a Python file with:
948        // - connected_caller() calls connected_callee() => both in call graph edges
949        // - standalone_func() calls nothing, called by nothing => NOT in any edge
950        let src = project.join("src");
951        std::fs::create_dir_all(&src).unwrap();
952
953        let module_path = src.join("module.py");
954        std::fs::write(
955            &module_path,
956            r#"
957def connected_caller():
958    return connected_callee()
959
960def connected_callee():
961    return 42
962
963def standalone_func():
964    """This function neither calls nor is called by anything."""
965    return "I exist but am isolated"
966"#,
967        )
968        .unwrap();
969
970        // Build call graph for the project
971        let call_graph = build_project_call_graph(project, Language::Python, None, true).unwrap();
972
973        // Call graph stores relative paths, so use those for matching
974        let changed_files = vec![PathBuf::from("src/module.py")];
975
976        let functions = find_functions_in_files(&call_graph, &changed_files, project);
977
978        // Should find ALL three functions, not just the two in call edges
979        let names: HashSet<&str> = functions.iter().map(|f| f.name.as_str()).collect();
980
981        assert!(
982            names.contains("connected_caller"),
983            "Should find connected_caller (it appears in call edges as source)"
984        );
985        assert!(
986            names.contains("connected_callee"),
987            "Should find connected_callee (it appears in call edges as destination)"
988        );
989        assert!(
990            names.contains("standalone_func"),
991            "Should find standalone_func even though it has no call edges. \
992             Found only: {:?}",
993            names
994        );
995    }
996
997    /// Test that find_functions_in_files discovers class methods that are standalone
998    /// (not referenced in any call graph edge).
999    #[test]
1000    fn test_find_functions_in_files_includes_standalone_methods() {
1001        use tempfile::TempDir;
1002
1003        let tmp = TempDir::new().unwrap();
1004        let project = tmp.path();
1005
1006        let src = project.join("src");
1007        std::fs::create_dir_all(&src).unwrap();
1008
1009        let module_path = src.join("myclass.py");
1010        std::fs::write(
1011            &module_path,
1012            r#"
1013class MyClass:
1014    def used_method(self):
1015        return self.helper()
1016
1017    def helper(self):
1018        return 42
1019
1020    def orphan_method(self):
1021        """Not called by anything, does not call anything."""
1022        return "orphan"
1023"#,
1024        )
1025        .unwrap();
1026
1027        let call_graph = build_project_call_graph(project, Language::Python, None, true).unwrap();
1028
1029        // Call graph stores relative paths
1030        let changed_files = vec![PathBuf::from("src/myclass.py")];
1031        let functions = find_functions_in_files(&call_graph, &changed_files, project);
1032        let names: HashSet<&str> = functions.iter().map(|f| f.name.as_str()).collect();
1033
1034        // orphan_method should be found even though it has no call edges
1035        assert!(
1036            names.contains("orphan_method") || names.contains("MyClass.orphan_method"),
1037            "Should find orphan_method even though it has no call edges. Found: {:?}",
1038            names
1039        );
1040    }
1041
1042    #[test]
1043    fn test_extract_go_test_functions() {
1044        let content = r#"
1045package auth
1046
1047func TestLogin(t *testing.T) {
1048    // test
1049}
1050
1051func TestLogout(t *testing.T) {
1052    // test
1053}
1054"#;
1055        let file = Path::new("auth_test.go");
1056        let functions = extract_test_functions_from_content(file, content, Language::Go);
1057
1058        assert_eq!(functions.len(), 2);
1059        assert!(functions.iter().any(|f| f.function == "TestLogin"));
1060        assert!(functions.iter().any(|f| f.function == "TestLogout"));
1061    }
1062}