1use std::collections::{HashMap, HashSet, VecDeque};
7use std::path::{Path, PathBuf};
8
9use crate::store::{CallGraph, CallerWithContext};
10use crate::Store;
11
12pub struct CallerInfo {
14 pub name: String,
15 pub file: PathBuf,
16 pub line: u32,
17 pub call_line: u32,
18 pub snippet: Option<String>,
19}
20
21pub struct TestInfo {
23 pub name: String,
24 pub file: PathBuf,
25 pub line: u32,
26 pub call_depth: usize,
27}
28
29pub struct TransitiveCaller {
31 pub name: String,
32 pub file: PathBuf,
33 pub line: u32,
34 pub depth: usize,
35}
36
37pub struct ImpactResult {
39 pub function_name: String,
40 pub callers: Vec<CallerInfo>,
41 pub tests: Vec<TestInfo>,
42 pub transitive_callers: Vec<TransitiveCaller>,
43}
44
45const MAX_TEST_SEARCH_DEPTH: usize = 5;
47
48pub fn analyze_impact(
50 store: &Store,
51 target_name: &str,
52 depth: usize,
53) -> anyhow::Result<ImpactResult> {
54 let callers = build_caller_info(store, target_name)?;
55 let graph = store.get_call_graph()?;
56 let tests = find_affected_tests(store, &graph, target_name)?;
57 let transitive_callers = if depth > 1 {
58 find_transitive_callers(store, &graph, target_name, depth)?
59 } else {
60 Vec::new()
61 };
62
63 Ok(ImpactResult {
64 function_name: target_name.to_string(),
65 callers,
66 tests,
67 transitive_callers,
68 })
69}
70
71pub struct FunctionHints {
73 pub caller_count: usize,
74 pub test_count: usize,
75}
76
77pub fn compute_hints_with_graph(
82 graph: &CallGraph,
83 test_chunks: &[crate::store::ChunkSummary],
84 function_name: &str,
85 prefetched_caller_count: Option<usize>,
86) -> FunctionHints {
87 let caller_count = match prefetched_caller_count {
88 Some(n) => n,
89 None => graph
90 .reverse
91 .get(function_name)
92 .map(|v| v.len())
93 .unwrap_or(0),
94 };
95 let ancestors = reverse_bfs(graph, function_name, MAX_TEST_SEARCH_DEPTH);
96 let test_count = test_chunks
97 .iter()
98 .filter(|t| ancestors.get(&t.name).is_some_and(|&d| d > 0))
99 .count();
100
101 FunctionHints {
102 caller_count,
103 test_count,
104 }
105}
106
107pub fn compute_hints(
113 store: &Store,
114 function_name: &str,
115 prefetched_caller_count: Option<usize>,
116) -> anyhow::Result<FunctionHints> {
117 let caller_count = match prefetched_caller_count {
118 Some(n) => n,
119 None => store.get_callers_full(function_name)?.len(),
120 };
121 let graph = store.get_call_graph()?;
122 let test_chunks = store.find_test_chunks()?;
123 Ok(compute_hints_with_graph(
124 &graph,
125 &test_chunks,
126 function_name,
127 Some(caller_count),
128 ))
129}
130
131fn build_caller_info(store: &Store, target_name: &str) -> anyhow::Result<Vec<CallerInfo>> {
133 let callers_ctx = store.get_callers_with_context(target_name)?;
134 let mut callers = Vec::with_capacity(callers_ctx.len());
135
136 for caller in &callers_ctx {
137 let snippet = extract_call_snippet(store, caller);
138 callers.push(CallerInfo {
139 name: caller.name.clone(),
140 file: caller.file.clone(),
141 line: caller.line,
142 call_line: caller.call_line,
143 snippet,
144 });
145 }
146
147 Ok(callers)
148}
149
150fn extract_call_snippet(store: &Store, caller: &CallerWithContext) -> Option<String> {
152 let result = match store.search_by_name(&caller.name, 1) {
153 Ok(results) => results.into_iter().next(),
154 Err(e) => {
155 tracing::warn!(caller = %caller.name, error = %e, "Failed to fetch call snippet");
156 return None;
157 }
158 };
159 result.and_then(|r| {
160 let lines: Vec<&str> = r.chunk.content.lines().collect();
161 let offset = caller.call_line.saturating_sub(r.chunk.line_start) as usize;
162 if offset < lines.len() {
163 let start = offset.saturating_sub(1);
164 let end = (offset + 2).min(lines.len());
165 Some(lines[start..end].join("\n"))
166 } else {
167 None
168 }
169 })
170}
171
172fn find_affected_tests(
174 store: &Store,
175 graph: &CallGraph,
176 target_name: &str,
177) -> anyhow::Result<Vec<TestInfo>> {
178 let test_chunks = store.find_test_chunks()?;
179 let ancestors = reverse_bfs(graph, target_name, MAX_TEST_SEARCH_DEPTH);
180
181 let mut tests: Vec<TestInfo> = test_chunks
182 .iter()
183 .filter_map(|test| {
184 ancestors.get(&test.name).and_then(|&d| {
185 if d > 0 {
186 Some(TestInfo {
187 name: test.name.clone(),
188 file: test.file.clone(),
189 line: test.line_start,
190 call_depth: d,
191 })
192 } else {
193 None
194 }
195 })
196 })
197 .collect();
198
199 tests.sort_by_key(|t| t.call_depth);
200 Ok(tests)
201}
202
203fn find_transitive_callers(
205 store: &Store,
206 graph: &CallGraph,
207 target_name: &str,
208 depth: usize,
209) -> anyhow::Result<Vec<TransitiveCaller>> {
210 let mut result = Vec::new();
211 let mut visited: HashSet<String> = HashSet::new();
212 visited.insert(target_name.to_string());
213 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
214 queue.push_back((target_name.to_string(), 0));
215
216 while let Some((current, d)) = queue.pop_front() {
217 if d >= depth {
218 continue;
219 }
220 if let Some(callers) = graph.reverse.get(¤t) {
221 for caller_name in callers {
222 if visited.insert(caller_name.clone()) {
223 match store.search_by_name(caller_name, 1) {
224 Ok(results) => {
225 if let Some(r) = results.into_iter().next() {
226 result.push(TransitiveCaller {
227 name: caller_name.clone(),
228 file: r.chunk.file,
229 line: r.chunk.line_start,
230 depth: d + 1,
231 });
232 }
233 }
234 Err(e) => {
235 tracing::warn!(caller = %caller_name, error = %e, "Failed to look up transitive caller");
236 }
237 }
238 queue.push_back((caller_name.clone(), d + 1));
239 }
240 }
241 }
242 }
243
244 Ok(result)
245}
246
247pub(crate) fn reverse_bfs(
249 graph: &CallGraph,
250 target: &str,
251 max_depth: usize,
252) -> HashMap<String, usize> {
253 let mut ancestors: HashMap<String, usize> = HashMap::new();
254 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
255 ancestors.insert(target.to_string(), 0);
256 queue.push_back((target.to_string(), 0));
257
258 while let Some((current, d)) = queue.pop_front() {
259 if d >= max_depth {
260 continue;
261 }
262 if let Some(callers) = graph.reverse.get(¤t) {
263 for caller in callers {
264 if !ancestors.contains_key(caller) {
265 ancestors.insert(caller.clone(), d + 1);
266 queue.push_back((caller.clone(), d + 1));
267 }
268 }
269 }
270 }
271
272 ancestors
273}
274
275pub fn impact_to_json(result: &ImpactResult, root: &Path) -> serde_json::Value {
279 let callers_json: Vec<_> = result
280 .callers
281 .iter()
282 .map(|c| {
283 let rel = rel_path(&c.file, root);
284 serde_json::json!({
285 "name": c.name,
286 "file": rel,
287 "line": c.line,
288 "call_line": c.call_line,
289 "snippet": c.snippet,
290 })
291 })
292 .collect();
293
294 let tests_json: Vec<_> = result
295 .tests
296 .iter()
297 .map(|t| {
298 let rel = rel_path(&t.file, root);
299 serde_json::json!({
300 "name": t.name,
301 "file": rel,
302 "line": t.line,
303 "call_depth": t.call_depth,
304 })
305 })
306 .collect();
307
308 let mut output = serde_json::json!({
309 "function": result.function_name,
310 "callers": callers_json,
311 "tests": tests_json,
312 "caller_count": callers_json.len(),
313 "test_count": tests_json.len(),
314 });
315
316 if !result.transitive_callers.is_empty() {
317 let trans_json: Vec<_> = result
318 .transitive_callers
319 .iter()
320 .map(|c| {
321 let rel = rel_path(&c.file, root);
322 serde_json::json!({
323 "name": c.name,
324 "file": rel,
325 "line": c.line,
326 "depth": c.depth,
327 })
328 })
329 .collect();
330 if let Some(obj) = output.as_object_mut() {
331 obj.insert("transitive_callers".into(), serde_json::json!(trans_json));
332 }
333 }
334
335 output
336}
337
338pub fn impact_to_mermaid(result: &ImpactResult, root: &Path) -> String {
342 let mut lines = vec!["graph TD".to_string()];
343 lines.push(format!(
344 " A[\"{}\"]\n style A fill:#f96",
345 mermaid_escape(&result.function_name)
346 ));
347
348 let mut idx = 1;
349 for c in &result.callers {
350 let rel = rel_path(&c.file, root);
351 let letter = node_letter(idx);
352 lines.push(format!(
353 " {}[\"{} ({}:{})\"]",
354 letter,
355 mermaid_escape(&c.name),
356 mermaid_escape(&rel),
357 c.line
358 ));
359 lines.push(format!(" {} --> A", letter));
360 idx += 1;
361 }
362
363 for t in &result.tests {
364 let rel = rel_path(&t.file, root);
365 let letter = node_letter(idx);
366 lines.push(format!(
367 " {}{{\"{}\\n{}\\ndepth: {}\"}}",
368 letter,
369 mermaid_escape(&t.name),
370 mermaid_escape(&rel),
371 t.call_depth
372 ));
373 lines.push(format!(" {} -.-> A", letter));
374 idx += 1;
375 }
376
377 lines.join("\n")
378}
379
380pub struct ChangedFunction {
384 pub name: String,
385 pub file: String,
386 pub line_start: u32,
387}
388
389pub struct DiffTestInfo {
391 pub name: String,
392 pub file: PathBuf,
393 pub line: u32,
394 pub via: String,
395 pub call_depth: usize,
396}
397
398pub struct DiffImpactSummary {
400 pub changed_count: usize,
401 pub caller_count: usize,
402 pub test_count: usize,
403}
404
405pub struct DiffImpactResult {
407 pub changed_functions: Vec<ChangedFunction>,
408 pub all_callers: Vec<CallerInfo>,
409 pub all_tests: Vec<DiffTestInfo>,
410 pub summary: DiffImpactSummary,
411}
412
413pub fn map_hunks_to_functions(
418 store: &Store,
419 hunks: &[crate::diff_parse::DiffHunk],
420) -> Vec<ChangedFunction> {
421 let mut seen = HashSet::new();
422 let mut functions = Vec::new();
423
424 let mut by_file: HashMap<&str, Vec<&crate::diff_parse::DiffHunk>> = HashMap::new();
426 for hunk in hunks {
427 by_file.entry(&hunk.file).or_default().push(hunk);
428 }
429
430 for (file, file_hunks) in &by_file {
431 let chunks = match store.get_chunks_by_origin(file) {
432 Ok(c) => c,
433 Err(_) => continue,
434 };
435 for hunk in file_hunks {
436 let hunk_end = hunk.start + hunk.count; for chunk in &chunks {
438 if hunk.start <= chunk.line_end
440 && hunk_end > chunk.line_start
441 && seen.insert(chunk.name.clone())
442 {
443 functions.push(ChangedFunction {
444 name: chunk.name.clone(),
445 file: file.to_string(),
446 line_start: chunk.line_start,
447 });
448 }
449 }
450 }
451 }
452
453 functions
454}
455
456pub fn analyze_diff_impact(
461 store: &Store,
462 changed: &[ChangedFunction],
463) -> anyhow::Result<DiffImpactResult> {
464 if changed.is_empty() {
465 return Ok(DiffImpactResult {
466 changed_functions: Vec::new(),
467 all_callers: Vec::new(),
468 all_tests: Vec::new(),
469 summary: DiffImpactSummary {
470 changed_count: 0,
471 caller_count: 0,
472 test_count: 0,
473 },
474 });
475 }
476
477 let graph = store.get_call_graph()?;
478 let test_chunks = store.find_test_chunks()?;
479
480 let mut all_callers = Vec::new();
481 let mut all_tests = Vec::new();
482 let mut seen_callers = HashSet::new();
483 let mut seen_tests = HashSet::new();
484
485 for func in changed {
486 if let Ok(callers_ctx) = store.get_callers_with_context(&func.name) {
488 for caller in &callers_ctx {
489 if seen_callers.insert(caller.name.clone()) {
490 let snippet = extract_call_snippet(store, caller);
491 all_callers.push(CallerInfo {
492 name: caller.name.clone(),
493 file: caller.file.clone(),
494 line: caller.line,
495 call_line: caller.call_line,
496 snippet,
497 });
498 }
499 }
500 }
501
502 let ancestors = reverse_bfs(&graph, &func.name, MAX_TEST_SEARCH_DEPTH);
504 for test in &test_chunks {
505 if let Some(&depth) = ancestors.get(&test.name) {
506 if depth > 0 && seen_tests.insert(test.name.clone()) {
507 all_tests.push(DiffTestInfo {
508 name: test.name.clone(),
509 file: test.file.clone(),
510 line: test.line_start,
511 via: func.name.clone(),
512 call_depth: depth,
513 });
514 }
515 }
516 }
517 }
518
519 all_tests.sort_by_key(|t| t.call_depth);
520
521 let summary = DiffImpactSummary {
522 changed_count: changed.len(),
523 caller_count: all_callers.len(),
524 test_count: all_tests.len(),
525 };
526
527 Ok(DiffImpactResult {
528 changed_functions: Vec::new(), all_callers,
530 all_tests,
531 summary,
532 })
533}
534
535pub fn diff_impact_to_json(result: &DiffImpactResult, root: &Path) -> serde_json::Value {
537 let changed_json: Vec<_> = result
538 .changed_functions
539 .iter()
540 .map(|f| {
541 serde_json::json!({
542 "name": f.name,
543 "file": f.file,
544 "line_start": f.line_start,
545 })
546 })
547 .collect();
548
549 let callers_json: Vec<_> = result
550 .all_callers
551 .iter()
552 .map(|c| {
553 let rel = rel_path(&c.file, root);
554 serde_json::json!({
555 "name": c.name,
556 "file": rel,
557 "line": c.line,
558 "call_line": c.call_line,
559 })
560 })
561 .collect();
562
563 let tests_json: Vec<_> = result
564 .all_tests
565 .iter()
566 .map(|t| {
567 let rel = rel_path(&t.file, root);
568 serde_json::json!({
569 "name": t.name,
570 "file": rel,
571 "line": t.line,
572 "via": t.via,
573 "call_depth": t.call_depth,
574 })
575 })
576 .collect();
577
578 serde_json::json!({
579 "changed_functions": changed_json,
580 "callers": callers_json,
581 "tests": tests_json,
582 "summary": {
583 "changed_count": result.summary.changed_count,
584 "caller_count": result.summary.caller_count,
585 "test_count": result.summary.test_count,
586 }
587 })
588}
589
590pub struct TestSuggestion {
594 pub test_name: String,
596 pub suggested_file: String,
598 pub for_function: String,
600 pub pattern_source: String,
602 pub inline: bool,
604}
605
606pub fn suggest_tests(store: &Store, impact: &ImpactResult) -> Vec<TestSuggestion> {
611 let graph = match store.get_call_graph() {
612 Ok(g) => g,
613 Err(_) => return Vec::new(),
614 };
615 let test_chunks = match store.find_test_chunks() {
616 Ok(t) => t,
617 Err(_) => return Vec::new(),
618 };
619
620 let mut suggestions = Vec::new();
621
622 for caller in &impact.callers {
623 let ancestors = reverse_bfs(&graph, &caller.name, MAX_TEST_SEARCH_DEPTH);
625 let is_tested = test_chunks
626 .iter()
627 .any(|t| ancestors.get(&t.name).is_some_and(|&d| d > 0));
628
629 if is_tested {
630 continue;
631 }
632
633 let file_chunks = store
635 .get_chunks_by_origin(&caller.file.to_string_lossy())
636 .ok()
637 .unwrap_or_default();
638
639 let is_test_chunk = |c: &crate::store::ChunkSummary| {
640 c.name.starts_with("test_") || c.name.starts_with("Test")
641 };
642
643 let has_inline_tests = file_chunks.iter().any(is_test_chunk);
644
645 let pattern_source = if has_inline_tests {
646 file_chunks
647 .iter()
648 .find(|c| is_test_chunk(c))
649 .map(|c| c.name.clone())
650 .unwrap_or_default()
651 } else {
652 String::new()
653 };
654
655 let language = file_chunks.first().map(|c| c.language);
656
657 let base_name = caller.name.trim_start_matches("self.");
659 let test_name = match language {
660 Some(crate::parser::Language::JavaScript | crate::parser::Language::TypeScript) => {
661 format!("test('{base_name}', ...)")
662 }
663 Some(crate::parser::Language::Java) if !base_name.is_empty() => {
664 let mut chars = base_name.chars();
666 let first = chars.next().unwrap().to_uppercase().to_string();
667 let rest: String = chars.collect();
668 format!("test{first}{rest}")
669 }
670 _ => {
671 format!("test_{base_name}")
673 }
674 };
675
676 let caller_file_str = caller.file.to_string_lossy().replace('\\', "/");
678
679 let suggested_file = if has_inline_tests {
680 caller_file_str.to_string()
681 } else {
682 suggest_test_file(&caller_file_str)
683 };
684
685 suggestions.push(TestSuggestion {
686 test_name,
687 suggested_file,
688 for_function: caller.name.clone(),
689 pattern_source,
690 inline: has_inline_tests,
691 });
692 }
693
694 suggestions
695}
696
697fn suggest_test_file(source: &str) -> String {
699 let path = std::path::Path::new(source);
701 let stem = path
702 .file_stem()
703 .and_then(|s| s.to_str())
704 .unwrap_or("unknown");
705 let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("rs");
706
707 let parent = path.parent().and_then(|p| p.to_str()).unwrap_or("tests");
709
710 match ext {
711 "rs" => format!("{parent}/tests/{stem}_test.rs"),
712 "py" => format!("{parent}/test_{stem}.py"),
713 "ts" | "tsx" => format!("{parent}/{stem}.test.ts"),
714 "js" | "jsx" => format!("{parent}/{stem}.test.js"),
715 "go" => format!("{parent}/{stem}_test.go"),
716 "java" => format!("{parent}/{stem}Test.java"),
717 _ => format!("{parent}/tests/{stem}_test.{ext}"),
718 }
719}
720
721fn rel_path(path: &Path, root: &Path) -> String {
724 path.strip_prefix(root)
725 .unwrap_or(path)
726 .to_string_lossy()
727 .replace('\\', "/")
728}
729
730fn node_letter(i: usize) -> String {
731 if i < 26 {
732 ((b'A' + i as u8) as char).to_string()
733 } else {
734 format!("{}{}", ((b'A' + (i % 26) as u8) as char), i / 26)
735 }
736}
737
738fn mermaid_escape(s: &str) -> String {
739 s.replace('"', """)
740 .replace('<', "<")
741 .replace('>', ">")
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 #[test]
751 fn test_suggest_test_file_rust() {
752 assert_eq!(
753 suggest_test_file("src/search.rs"),
754 "src/tests/search_test.rs"
755 );
756 }
757
758 #[test]
759 fn test_suggest_test_file_python() {
760 assert_eq!(suggest_test_file("src/search.py"), "src/test_search.py");
761 }
762
763 #[test]
764 fn test_suggest_test_file_typescript() {
765 assert_eq!(suggest_test_file("src/search.ts"), "src/search.test.ts");
766 }
767
768 #[test]
769 fn test_suggest_test_file_javascript() {
770 assert_eq!(suggest_test_file("src/search.js"), "src/search.test.js");
771 }
772
773 #[test]
774 fn test_suggest_test_file_go() {
775 assert_eq!(suggest_test_file("pkg/search.go"), "pkg/search_test.go");
776 }
777
778 #[test]
779 fn test_suggest_test_file_java() {
780 assert_eq!(suggest_test_file("src/Search.java"), "src/SearchTest.java");
781 }
782
783 #[test]
786 fn test_reverse_bfs_empty_graph() {
787 let graph = CallGraph {
788 forward: HashMap::new(),
789 reverse: HashMap::new(),
790 };
791 let result = reverse_bfs(&graph, "target", 5);
792 assert_eq!(result.len(), 1); assert_eq!(result["target"], 0);
794 }
795
796 #[test]
797 fn test_reverse_bfs_chain() {
798 let mut reverse = HashMap::new();
799 reverse.insert("C".to_string(), vec!["B".to_string()]);
800 reverse.insert("B".to_string(), vec!["A".to_string()]);
801 let graph = CallGraph {
802 forward: HashMap::new(),
803 reverse,
804 };
805 let result = reverse_bfs(&graph, "C", 5);
806 assert_eq!(result["C"], 0);
807 assert_eq!(result["B"], 1);
808 assert_eq!(result["A"], 2);
809 }
810
811 #[test]
812 fn test_reverse_bfs_respects_depth() {
813 let mut reverse = HashMap::new();
814 reverse.insert("C".to_string(), vec!["B".to_string()]);
815 reverse.insert("B".to_string(), vec!["A".to_string()]);
816 let graph = CallGraph {
817 forward: HashMap::new(),
818 reverse,
819 };
820 let result = reverse_bfs(&graph, "C", 1);
821 assert_eq!(result.len(), 2); assert!(!result.contains_key("A")); }
824
825 #[test]
828 fn test_suggest_tests_no_callers() {
829 let result = ImpactResult {
831 function_name: "target_fn".to_string(),
832 callers: Vec::new(),
833 tests: Vec::new(),
834 transitive_callers: Vec::new(),
835 };
836 assert!(result.callers.is_empty());
839 }
840}