1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ChangeImpactReport {
33 pub changed_files: Vec<PathBuf>,
35 pub affected_tests: Vec<PathBuf>,
37 #[serde(default)]
39 pub affected_test_functions: Vec<TestFunction>,
40 pub affected_functions: Vec<FunctionRef>,
42 pub detection_method: String,
44 #[serde(default)]
46 pub metadata: Option<ChangeImpactMetadata>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct TestFunction {
52 pub file: PathBuf,
54 pub function: String,
56 pub class: Option<String>,
58 pub line: u32,
60}
61
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct ChangeImpactMetadata {
65 pub language: String,
67 pub call_graph_nodes: usize,
69 pub call_graph_edges: usize,
71 pub analysis_depth: Option<usize>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum DetectionMethod {
78 GitHead,
80 GitBase {
82 base: String,
84 },
85 GitStaged,
87 GitUncommitted,
89 Explicit,
91 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
108pub fn change_impact(
131 project: &Path,
132 changed_files: Option<&[PathBuf]>,
133 language: Language,
134) -> TldrResult<ChangeImpactReport> {
135 let (method, explicit) = if let Some(files) = changed_files {
137 if files.is_empty() {
138 (DetectionMethod::GitHead, None)
140 } else {
141 (DetectionMethod::Explicit, Some(files.to_vec()))
143 }
144 } else {
145 (DetectionMethod::GitHead, None)
147 };
148
149 change_impact_extended(
150 project,
151 method,
152 language,
153 10, true, &[], explicit,
157 )
158}
159
160pub fn change_impact_extended(
174 project: &Path,
175 method: DetectionMethod,
176 language: Language,
177 depth: usize,
178 _include_imports: bool, _test_patterns: &[String], explicit_files: Option<Vec<PathBuf>>,
181) -> TldrResult<ChangeImpactReport> {
182 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()), Err(_) => (vec![], DetectionMethod::Session), }
194 }
195 DetectionMethod::GitBase { base } => {
196 match detect_git_changes_base(project, base) {
197 Ok(files) => (files, method.clone()),
198 Err(e) => {
199 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 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 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 let call_graph = build_project_call_graph(project, language, None, true)?;
249
250 let changed_functions = find_functions_in_files(&call_graph, &changed_files, project);
252
253 let affected_functions =
255 find_affected_functions_with_depth(&call_graph, &changed_functions, depth);
256
257 let all_files = get_all_project_files(project, language)?;
259
260 let test_files: HashSet<PathBuf> = all_files
262 .iter()
263 .filter(|f| is_test_file(f, language))
264 .cloned()
265 .collect();
266
267 let affected_tests = find_affected_tests(
273 &test_files,
274 &changed_files,
275 &affected_functions,
276 &call_graph,
277 );
278
279 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, call_graph_edges: edge_count,
294 analysis_depth: Some(depth),
295 })
296 },
297 })
298}
299
300fn 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
318fn 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; let trimmed = line.trim();
330 let is_indented = line.starts_with(" ") || line.starts_with("\t");
331
332 match language {
333 Language::Python => {
334 if trimmed.starts_with("class ") && !is_indented {
336 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 if trimmed.starts_with("def ") || trimmed.starts_with("async def ") {
351 current_class = None;
353 } else if !trimmed.starts_with("class ") {
354 current_class = None;
356 }
357 }
358
359 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 if trimmed.starts_with("test(") || trimmed.starts_with("it(") {
382 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 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 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 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 if trimmed.contains("test") && trimmed.contains("fn ") {
445 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
465fn 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
475fn detect_git_changes_base(project: &Path, base: &str) -> TldrResult<Vec<PathBuf>> {
478 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 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
512fn 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
522fn detect_git_changes_uncommitted(project: &Path) -> TldrResult<Vec<PathBuf>> {
524 let staged = Command::new("git")
526 .args(["diff", "--name-only", "--staged"])
527 .current_dir(project)
528 .output();
529
530 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
565fn 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
597fn 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 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 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 for func in &module_info.functions {
645 functions.insert(FunctionRef::new(file.clone(), func.name.clone()));
646 }
647 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 eprintln!(
660 "Warning: AST extraction failed for {}: {}",
661 absolute_path.display(),
662 e
663 );
664 }
665 }
666 }
667
668 functions
669}
670
671fn 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 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 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 if depth >= max_depth {
695 continue;
696 }
697
698 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
711fn 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
727fn 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
744fn 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 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 file_name.contains("test") || in_tests_dir()
780 }
781 }
782}
783
784fn 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 for file in changed_files {
795 if test_files.contains(file) {
796 affected_tests.insert(file.clone());
797 }
798 }
799
800 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 let changed_file_set: HashSet<&PathBuf> = changed_files.iter().collect();
810 for edge in call_graph.edges() {
811 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 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]
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 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 let call_graph = build_project_call_graph(project, Language::Python, None, true).unwrap();
972
973 let changed_files = vec![PathBuf::from("src/module.py")];
975
976 let functions = find_functions_in_files(&call_graph, &changed_files, project);
977
978 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]
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 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 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}