Skip to main content

cqs/
impact.rs

1//! Impact analysis core
2//!
3//! Provides BFS caller traversal, test discovery, snippet extraction,
4//! transitive caller analysis, and mermaid diagram generation.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::path::{Path, PathBuf};
8
9use crate::store::{CallGraph, CallerWithContext};
10use crate::Store;
11
12/// Direct caller with display-ready fields
13pub 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
21/// Affected test with call depth
22pub struct TestInfo {
23    pub name: String,
24    pub file: PathBuf,
25    pub line: u32,
26    pub call_depth: usize,
27}
28
29/// Transitive caller at a given depth
30pub struct TransitiveCaller {
31    pub name: String,
32    pub file: PathBuf,
33    pub line: u32,
34    pub depth: usize,
35}
36
37/// Complete impact analysis result
38pub 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
45/// Maximum depth for test search BFS
46const MAX_TEST_SEARCH_DEPTH: usize = 5;
47
48/// Run impact analysis: find callers, affected tests, and transitive callers.
49pub 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
71/// Lightweight caller + test coverage hints for a function.
72pub struct FunctionHints {
73    pub caller_count: usize,
74    pub test_count: usize,
75}
76
77/// Core implementation — accepts pre-loaded graph and test chunks.
78///
79/// Use this when processing multiple functions to avoid loading the graph
80/// N times (e.g., scout, which processes 10+ functions).
81pub 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
107/// Compute caller count and test count for a single function.
108///
109/// Convenience wrapper that loads graph internally. Pass `prefetched_caller_count`
110/// to avoid re-querying callers when the caller already has them (e.g., `explain`
111/// fetches callers before this).
112pub 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
131/// Build caller info with call-site snippets
132fn 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
150/// Extract a snippet around the call site from the caller's indexed content
151fn 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
172/// Find tests that transitively call the target via reverse BFS
173fn 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
203/// Find transitive callers up to the given depth
204fn 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(&current) {
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
247/// Reverse BFS from a target node, returning all ancestors with their depths
248pub(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(&current) {
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
275// ============ JSON Serialization ============
276
277/// Serialize impact result to JSON, relativizing paths against the project root
278pub 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
338// ============ Mermaid Diagram ============
339
340/// Generate a mermaid diagram from impact result
341pub 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
380// ============ Diff-Aware Impact ============
381
382/// A function identified as changed by a diff
383pub struct ChangedFunction {
384    pub name: String,
385    pub file: String,
386    pub line_start: u32,
387}
388
389/// A test affected by diff changes, tracking which changed function leads to it
390pub 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
398/// Summary counts for diff impact
399pub struct DiffImpactSummary {
400    pub changed_count: usize,
401    pub caller_count: usize,
402    pub test_count: usize,
403}
404
405/// Aggregated impact result from a diff
406pub 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
413/// Map diff hunks to function names using the index.
414///
415/// For each hunk, finds chunks whose line range overlaps the hunk's range.
416/// Returns deduplicated function names.
417pub 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    // Group hunks by file
425    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; // exclusive
437            for chunk in &chunks {
438                // Overlap: hunk [start, start+count) vs chunk [line_start, line_end]
439                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
456/// Run impact analysis across all changed functions from a diff.
457///
458/// Fetches call graph and test chunks once, then analyzes each function.
459/// Results are deduplicated by name.
460pub 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        // Direct callers
487        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        // Affected tests via reverse BFS
503        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(), // filled by caller
529        all_callers,
530        all_tests,
531        summary,
532    })
533}
534
535/// Serialize diff impact result to JSON
536pub 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
590// ============ Test Suggestions ============
591
592/// A suggested test for an untested caller
593pub struct TestSuggestion {
594    /// Suggested test function name
595    pub test_name: String,
596    /// Suggested file for the test
597    pub suggested_file: String,
598    /// The untested function this test would cover
599    pub for_function: String,
600    /// Where the naming pattern came from (empty if default)
601    pub pattern_source: String,
602    /// Whether to put the test inline (vs external test file)
603    pub inline: bool,
604}
605
606/// Suggest tests for untested callers in an impact result.
607///
608/// Loads its own call graph and test chunks — only called when `--suggest-tests`
609/// is set, so the normal path pays zero overhead.
610pub 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        // Check if this caller is reached by ANY test (not just the target's tests)
624        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        // Fetch file chunks once for inline test check, pattern, and language
634        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        // Generate test name based on language
658        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                // Java: camelCase testMethodName
665                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                // Rust, Python, Go, C, SQL, Markdown — all use snake_case test_ prefix
672                format!("test_{base_name}")
673            }
674        };
675
676        // Suggest file location
677        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
697/// Derive a test file path from a source file path.
698fn suggest_test_file(source: &str) -> String {
699    // Extract the filename stem and extension
700    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    // Find the nearest parent directory
708    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
721// ============ Helpers ============
722
723fn 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('"', "&quot;")
740        .replace('<', "&lt;")
741        .replace('>', "&gt;")
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    // ===== suggest_test_file tests =====
749
750    #[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    // ===== reverse_bfs tests =====
784
785    #[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); // Just the target itself at depth 0
793        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); // C at 0, B at 1
822        assert!(!result.contains_key("A")); // Beyond depth limit
823    }
824
825    // ===== suggest_tests edge case — empty callers =====
826
827    #[test]
828    fn test_suggest_tests_no_callers() {
829        // ImpactResult with no callers should produce no suggestions
830        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        // We can't call suggest_tests without a store, but we can verify
837        // the empty callers path directly: zero callers → zero iterations
838        assert!(result.callers.is_empty());
839    }
840}