use std::collections::{BTreeSet, HashMap, VecDeque};
use std::sync::OnceLock;
use crate::store::CallGraph;
const DEFAULT_BFS_MAX_NODES: usize = 10_000;
pub(super) fn bfs_max_nodes() -> usize {
static CAP: OnceLock<usize> = OnceLock::new();
*CAP.get_or_init(|| match std::env::var("CQS_IMPACT_MAX_NODES") {
Ok(val) => match val.parse::<usize>() {
Ok(n) if n > 0 => {
tracing::info!(cap = n, "BFS node cap overridden via CQS_IMPACT_MAX_NODES");
n
}
_ => {
tracing::warn!(
val,
"CQS_IMPACT_MAX_NODES invalid, using default {DEFAULT_BFS_MAX_NODES}"
);
DEFAULT_BFS_MAX_NODES
}
},
Err(_) => DEFAULT_BFS_MAX_NODES,
})
}
pub(super) fn reverse_bfs(
graph: &CallGraph,
target: &str,
max_depth: usize,
) -> HashMap<String, usize> {
let mut ancestors: HashMap<String, usize> = HashMap::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
ancestors.insert(target.to_string(), 0);
queue.push_back((target.to_string(), 0));
while let Some((current, d)) = queue.pop_front() {
if d >= max_depth {
continue;
}
if ancestors.len() >= bfs_max_nodes() {
tracing::warn!(
target,
nodes = ancestors.len(),
"reverse_bfs hit node cap, returning partial results"
);
break;
}
if let Some(callers) = graph.reverse.get(current.as_str()) {
for caller in callers {
if ancestors.len() >= bfs_max_nodes() {
break;
}
if !ancestors.contains_key(caller.as_ref()) {
ancestors.insert(caller.to_string(), d + 1);
queue.push_back((caller.to_string(), d + 1));
}
}
}
}
ancestors
}
#[cfg(test)]
pub(super) fn reverse_bfs_multi(
graph: &CallGraph,
targets: &[&str],
max_depth: usize,
) -> HashMap<String, usize> {
let mut ancestors: HashMap<String, usize> = HashMap::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
for &target in targets {
ancestors.insert(target.to_string(), 0);
queue.push_back((target.to_string(), 0));
}
while let Some((current, d)) = queue.pop_front() {
if d >= max_depth {
continue;
}
if ancestors.len() >= bfs_max_nodes() {
tracing::warn!(
nodes = ancestors.len(),
"reverse_bfs_multi hit node cap, returning partial results"
);
break;
}
if ancestors.get(¤t).is_some_and(|&stored| d > stored) {
continue;
}
if let Some(callers) = graph.reverse.get(current.as_str()) {
for caller in callers {
if ancestors.len() >= bfs_max_nodes() {
break;
}
match ancestors.entry(caller.to_string()) {
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(d + 1);
queue.push_back((caller.to_string(), d + 1));
}
std::collections::hash_map::Entry::Occupied(mut e) => {
if d + 1 < *e.get() {
*e.get_mut() = d + 1;
queue.push_back((caller.to_string(), d + 1));
}
}
}
}
}
}
ancestors
}
pub(super) fn reverse_bfs_multi_attributed(
graph: &CallGraph,
targets: &[&str],
max_depth: usize,
) -> HashMap<String, (usize, usize)> {
let mut ancestors: HashMap<String, (usize, usize)> = HashMap::new();
let mut queue: VecDeque<(String, usize, usize)> = VecDeque::new();
for (idx, &target) in targets.iter().enumerate() {
match ancestors.entry(target.to_string()) {
std::collections::hash_map::Entry::Vacant(e) => {
e.insert((0, idx));
queue.push_back((target.to_string(), 0, idx));
}
std::collections::hash_map::Entry::Occupied(_) => {
}
}
}
while let Some((current, d, src)) = queue.pop_front() {
if d >= max_depth {
continue;
}
if ancestors.len() >= bfs_max_nodes() {
tracing::warn!(
nodes = ancestors.len(),
"reverse_bfs_multi_attributed hit node cap, returning partial results"
);
break;
}
if ancestors
.get(¤t)
.is_some_and(|&(stored_d, _)| d > stored_d)
{
continue;
}
if let Some(callers) = graph.reverse.get(current.as_str()) {
for caller in callers {
if ancestors.len() >= bfs_max_nodes() {
break;
}
match ancestors.entry(caller.to_string()) {
std::collections::hash_map::Entry::Vacant(e) => {
e.insert((d + 1, src));
queue.push_back((caller.to_string(), d + 1, src));
}
std::collections::hash_map::Entry::Occupied(mut e) => {
if d + 1 < e.get().0 {
*e.get_mut() = (d + 1, src);
queue.push_back((caller.to_string(), d + 1, src));
}
}
}
}
}
}
ancestors
}
pub(crate) fn test_reachability(
graph: &CallGraph,
test_names: &[&str],
max_depth: usize,
) -> HashMap<String, usize> {
let _span = tracing::info_span!("test_reachability", tests = test_names.len()).entered();
let mut counts: HashMap<String, usize> = HashMap::new();
let mut equivalence_classes: HashMap<BTreeSet<&str>, usize> = HashMap::new();
for &test_name in test_names {
let callees: BTreeSet<&str> = graph
.forward
.get(test_name)
.map(|v| v.iter().map(|s| s.as_ref()).collect())
.unwrap_or_default();
*equivalence_classes.entry(callees).or_default() += 1;
}
let mut visited: HashMap<String, usize> = HashMap::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
for (callee_set, class_size) in &equivalence_classes {
if callee_set.is_empty() {
continue;
}
visited.clear();
queue.clear();
for &callee in callee_set {
visited.insert(callee.to_string(), 1);
queue.push_back((callee.to_string(), 1));
}
while let Some((current, d)) = queue.pop_front() {
if d >= max_depth {
continue;
}
if visited.len() >= bfs_max_nodes() {
tracing::warn!(
nodes = visited.len(),
"test_reachability BFS hit node cap, returning partial results"
);
break;
}
if let Some(callees) = graph.forward.get(current.as_str()) {
for callee in callees {
if visited.len() >= bfs_max_nodes() {
break;
}
if !visited.contains_key(callee.as_ref()) {
visited.insert(callee.to_string(), d + 1);
queue.push_back((callee.to_string(), d + 1));
}
}
}
}
for name in visited.keys() {
*counts.entry(name.clone()).or_default() += class_size;
}
}
counts
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_reverse_bfs_empty_graph() {
let graph = CallGraph::from_string_maps(HashMap::new(), HashMap::new());
let result = reverse_bfs(&graph, "target", 5);
assert_eq!(result.len(), 1); assert_eq!(result["target"], 0);
}
#[test]
fn test_reverse_bfs_chain() {
let mut reverse = HashMap::new();
reverse.insert("C".to_string(), vec!["B".to_string()]);
reverse.insert("B".to_string(), vec!["A".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let result = reverse_bfs(&graph, "C", 5);
assert_eq!(result["C"], 0);
assert_eq!(result["B"], 1);
assert_eq!(result["A"], 2);
}
#[test]
fn test_reverse_bfs_respects_depth() {
let mut reverse = HashMap::new();
reverse.insert("C".to_string(), vec!["B".to_string()]);
reverse.insert("B".to_string(), vec!["A".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let result = reverse_bfs(&graph, "C", 1);
assert_eq!(result.len(), 2); assert!(!result.contains_key("A")); }
#[test]
fn test_reverse_bfs_multi_empty_targets() {
let graph = CallGraph::from_string_maps(HashMap::new(), HashMap::new());
let result = reverse_bfs_multi(&graph, &[], 5);
assert!(
result.is_empty(),
"Empty targets should produce empty result"
);
}
#[test]
fn test_reverse_bfs_multi_non_overlapping_ancestors() {
let mut reverse = HashMap::new();
reverse.insert("target1".to_string(), vec!["B".to_string()]);
reverse.insert("B".to_string(), vec!["A".to_string()]);
reverse.insert("target2".to_string(), vec!["D".to_string()]);
reverse.insert("D".to_string(), vec!["C".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let result = reverse_bfs_multi(&graph, &["target1", "target2"], 5);
assert_eq!(result["target1"], 0);
assert_eq!(result["target2"], 0);
assert_eq!(result["B"], 1);
assert_eq!(result["A"], 2);
assert_eq!(result["D"], 1);
assert_eq!(result["C"], 2);
assert_eq!(result.len(), 6); }
#[test]
fn test_reverse_bfs_multi_shared_ancestor() {
let mut reverse = HashMap::new();
reverse.insert("target1".to_string(), vec!["mid".to_string()]);
reverse.insert("mid".to_string(), vec!["shared".to_string()]);
reverse.insert("target2".to_string(), vec!["shared".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let result = reverse_bfs_multi(&graph, &["target1", "target2"], 5);
assert_eq!(result["target1"], 0);
assert_eq!(result["target2"], 0);
assert_eq!(result["mid"], 1);
assert_eq!(
result["shared"], 1,
"Shared ancestor should get minimum depth across all sources"
);
}
#[test]
fn test_reverse_bfs_multi_depth_limit() {
let mut reverse = HashMap::new();
reverse.insert("target1".to_string(), vec!["C".to_string()]);
reverse.insert("C".to_string(), vec!["B".to_string()]);
reverse.insert("B".to_string(), vec!["A".to_string()]);
reverse.insert("target2".to_string(), vec!["D".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let result = reverse_bfs_multi(&graph, &["target1", "target2"], 1);
assert_eq!(result["target1"], 0);
assert_eq!(result["target2"], 0);
assert_eq!(result["C"], 1, "Direct caller of target1 should be found");
assert_eq!(result["D"], 1, "Direct caller of target2 should be found");
assert!(
!result.contains_key("B"),
"B is at depth 2 from target1, beyond limit"
);
assert!(
!result.contains_key("A"),
"A is at depth 3 from target1, beyond limit"
);
}
#[test]
fn test_reverse_bfs_multi_single_target_matches_reverse_bfs() {
let mut reverse = HashMap::new();
reverse.insert("C".to_string(), vec!["B".to_string()]);
reverse.insert("B".to_string(), vec!["A".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let single = reverse_bfs(&graph, "C", 5);
let multi = reverse_bfs_multi(&graph, &["C"], 5);
assert_eq!(
single, multi,
"Single-target multi should match reverse_bfs"
);
}
#[test]
fn test_reverse_bfs_multi_diamond_graph() {
let mut reverse = HashMap::new();
reverse.insert("D".to_string(), vec!["B".to_string(), "C".to_string()]);
reverse.insert("B".to_string(), vec!["A".to_string()]);
reverse.insert("C".to_string(), vec!["A".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let result = reverse_bfs_multi(&graph, &["D", "B"], 5);
assert_eq!(result["D"], 0);
assert_eq!(result["B"], 0); assert_eq!(result["C"], 1); assert_eq!(
result["A"], 1,
"A is at depth 1 from B (target), not depth 2 from D"
);
}
#[test]
fn test_reverse_bfs_multi_stale_queue_entry() {
let mut reverse = HashMap::new();
reverse.insert("target1".to_string(), vec!["mid".to_string()]);
reverse.insert("mid".to_string(), vec!["deep".to_string()]);
reverse.insert("target2".to_string(), vec!["deep".to_string()]);
reverse.insert("deep".to_string(), vec!["root".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let result = reverse_bfs_multi(&graph, &["target1", "target2"], 5);
assert_eq!(result["target1"], 0);
assert_eq!(result["target2"], 0);
assert_eq!(result["mid"], 1);
assert_eq!(
result["deep"], 1,
"deep should be depth 1 (from target2), not 2 (from target1->mid)"
);
assert_eq!(
result["root"], 2,
"root should be depth 2 (deep+1), not 3 (from stale queue entry)"
);
}
#[test]
fn test_attributed_empty_targets() {
let graph = CallGraph::from_string_maps(HashMap::new(), HashMap::new());
let result = reverse_bfs_multi_attributed(&graph, &[], 5);
assert!(result.is_empty());
}
#[test]
fn test_attributed_single_target() {
let mut reverse = HashMap::new();
reverse.insert("target".to_string(), vec!["B".to_string()]);
reverse.insert("B".to_string(), vec!["A".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let result = reverse_bfs_multi_attributed(&graph, &["target"], 5);
assert_eq!(result["target"], (0, 0));
assert_eq!(result["B"], (1, 0));
assert_eq!(result["A"], (2, 0));
}
#[test]
fn test_attributed_two_sources_separate_chains() {
let mut reverse = HashMap::new();
reverse.insert("target0".to_string(), vec!["A".to_string()]);
reverse.insert("target1".to_string(), vec!["B".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let result = reverse_bfs_multi_attributed(&graph, &["target0", "target1"], 5);
assert_eq!(result["target0"], (0, 0));
assert_eq!(result["target1"], (0, 1));
assert_eq!(result["A"], (1, 0));
assert_eq!(result["B"], (1, 1));
}
#[test]
fn test_attributed_shared_ancestor_gets_closest_source() {
let mut reverse = HashMap::new();
reverse.insert("target0".to_string(), vec!["mid".to_string()]);
reverse.insert("mid".to_string(), vec!["shared".to_string()]);
reverse.insert("target1".to_string(), vec!["shared".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let result = reverse_bfs_multi_attributed(&graph, &["target0", "target1"], 5);
assert_eq!(result["shared"].0, 1, "min depth is 1 from target1");
assert_eq!(result["shared"].1, 1, "attributed to target1 (index 1)");
}
#[test]
fn test_attributed_depth_matches_multi() {
let mut reverse = HashMap::new();
reverse.insert("target0".to_string(), vec!["mid".to_string()]);
reverse.insert("mid".to_string(), vec!["shared".to_string()]);
reverse.insert("target1".to_string(), vec!["shared".to_string()]);
reverse.insert("shared".to_string(), vec!["root".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let targets = &["target0", "target1"];
let multi = reverse_bfs_multi(&graph, targets, 5);
let attributed = reverse_bfs_multi_attributed(&graph, targets, 5);
for (name, &depth) in &multi {
assert_eq!(
attributed[name].0, depth,
"depth mismatch for {name}: multi={depth}, attributed={}",
attributed[name].0
);
}
assert_eq!(multi.len(), attributed.len());
}
#[test]
fn test_attributed_stale_queue_entry() {
let mut reverse = HashMap::new();
reverse.insert("target1".to_string(), vec!["mid".to_string()]);
reverse.insert("mid".to_string(), vec!["deep".to_string()]);
reverse.insert("target2".to_string(), vec!["deep".to_string()]);
reverse.insert("deep".to_string(), vec!["root".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let result = reverse_bfs_multi_attributed(&graph, &["target1", "target2"], 5);
assert_eq!(result["deep"].0, 1, "depth 1 from target2");
assert_eq!(result["deep"].1, 1, "attributed to target2 (index 1)");
assert_eq!(result["root"].0, 2, "root at depth 2, not 3");
assert_eq!(result["root"].1, 1, "root attributed via target2's chain");
}
#[test]
fn test_attributed_depth_limit() {
let mut reverse = HashMap::new();
reverse.insert("target0".to_string(), vec!["C".to_string()]);
reverse.insert("C".to_string(), vec!["B".to_string()]);
reverse.insert("B".to_string(), vec!["A".to_string()]);
reverse.insert("target1".to_string(), vec!["D".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let result = reverse_bfs_multi_attributed(&graph, &["target0", "target1"], 1);
assert_eq!(result["C"], (1, 0));
assert_eq!(result["D"], (1, 1));
assert!(!result.contains_key("B"), "B beyond depth limit");
assert!(!result.contains_key("A"), "A beyond depth limit");
}
#[test]
fn test_reachability_empty_graph() {
let graph = CallGraph::from_string_maps(HashMap::new(), HashMap::new());
let result = test_reachability(&graph, &["test_a"], 5);
assert!(
result.is_empty(),
"No forward edges means nothing reachable"
);
}
#[test]
fn test_reachability_single_test() {
let mut forward = HashMap::new();
forward.insert("test_a".to_string(), vec!["B".to_string()]);
forward.insert("B".to_string(), vec!["C".to_string()]);
let graph = CallGraph::from_string_maps(forward, HashMap::new());
let result = test_reachability(&graph, &["test_a"], 5);
assert_eq!(result.get("B"), Some(&1), "B reachable from test_a");
assert_eq!(result.get("C"), Some(&1), "C reachable from test_a");
assert!(
!result.contains_key("test_a"),
"Test itself excluded (depth 0)"
);
}
#[test]
fn test_reachability_multiple_tests_shared_target() {
let mut forward = HashMap::new();
forward.insert("test_a".to_string(), vec!["target".to_string()]);
forward.insert("test_b".to_string(), vec!["target".to_string()]);
let graph = CallGraph::from_string_maps(forward, HashMap::new());
let result = test_reachability(&graph, &["test_a", "test_b"], 5);
assert_eq!(
result.get("target"),
Some(&2),
"target reachable from both tests"
);
}
#[test]
fn test_reachability_respects_depth() {
let mut forward = HashMap::new();
forward.insert("test_a".to_string(), vec!["B".to_string()]);
forward.insert("B".to_string(), vec!["C".to_string()]);
forward.insert("C".to_string(), vec!["D".to_string()]);
let graph = CallGraph::from_string_maps(forward, HashMap::new());
let result = test_reachability(&graph, &["test_a"], 2);
assert_eq!(result.get("B"), Some(&1), "B at depth 1");
assert_eq!(result.get("C"), Some(&1), "C at depth 2");
assert!(!result.contains_key("D"), "D beyond depth limit");
}
}