use std::collections::{HashMap, VecDeque};
use crate::model::{EdgeType, Graph, NodeId};
use super::{TraceCycle, TraceEdge, TraceNode, TracePath};
pub struct CallGraphTracer<'a> {
graph: &'a Graph,
}
struct WorkPath {
visited_ids: Vec<NodeId>,
path: TracePath,
}
impl<'a> CallGraphTracer<'a> {
#[must_use]
pub fn new(graph: &'a Graph) -> Self {
Self { graph }
}
pub fn trace(&self, start_id: &NodeId, depth: usize) -> Vec<TracePath> {
let Some(start_node) = self.graph.get_node(start_id) else {
return Vec::new();
};
let mut queue: VecDeque<WorkPath> = VecDeque::new();
queue.push_back(WorkPath {
visited_ids: vec![start_id.clone()],
path: TracePath {
nodes: vec![TraceNode::from(start_node)],
edges: Vec::new(),
depth: 0,
},
});
let mut results = Vec::new();
while let Some(work) = queue.pop_front() {
let has_edges = !work.path.edges.is_empty();
let can_extend = work.path.depth < depth;
if !can_extend {
if has_edges {
results.push(work.path);
}
continue;
}
let current_id = work
.visited_ids
.last()
.expect("work path always has at least one visited id")
.clone();
for edge in self.graph.edges_from(¤t_id) {
if !matches!(edge.edge_type, EdgeType::Calls | EdgeType::FfiCalls) {
continue;
}
let Some(target_node) = self.graph.get_node(&edge.target) else {
continue;
};
if work.visited_ids.contains(&edge.target) {
continue;
}
let mut new_visited = work.visited_ids.clone();
new_visited.push(edge.target.clone());
let mut new_path = work.path.clone();
new_path.nodes.push(TraceNode::from(target_node));
new_path.edges.push(TraceEdge {
edge_type: edge.edge_type.to_string(),
reason: edge.reason.clone(),
confidence: edge.confidence,
});
new_path.depth = work.path.depth + 1;
queue.push_back(WorkPath {
visited_ids: new_visited,
path: new_path,
});
}
if has_edges {
results.push(work.path);
}
}
results
}
#[must_use]
pub fn detect_cycles(&self) -> Vec<TraceCycle> {
let mut colors: HashMap<NodeId, DfsColor> = HashMap::new();
let mut stack: Vec<NodeId> = Vec::new();
let mut stack_edges: Vec<EdgeType> = Vec::new();
let mut cycles: Vec<TraceCycle> = Vec::new();
let mut node_ids: Vec<&NodeId> = self.graph.nodes.keys().collect();
node_ids.sort();
for node_id in node_ids {
if !colors.contains_key(node_id) {
self.dfs_cycles(
node_id,
&mut colors,
&mut stack,
&mut stack_edges,
&mut cycles,
);
}
}
cycles
}
fn dfs_cycles(
&self,
node_id: &NodeId,
colors: &mut HashMap<NodeId, DfsColor>,
stack: &mut Vec<NodeId>,
stack_edges: &mut Vec<EdgeType>,
cycles: &mut Vec<TraceCycle>,
) {
colors.insert(node_id.clone(), DfsColor::Gray);
stack.push(node_id.clone());
let mut edges: Vec<&crate::model::Edge> = self
.graph
.edges_from(node_id)
.into_iter()
.filter(|e| matches!(e.edge_type, EdgeType::Calls | EdgeType::FfiCalls))
.collect();
edges.sort_by(|a, b| a.target.cmp(&b.target));
for edge in edges {
match colors.get(&edge.target).copied() {
None => {
stack_edges.push(edge.edge_type);
self.dfs_cycles(&edge.target, colors, stack, stack_edges, cycles);
stack_edges.pop();
}
Some(DfsColor::Gray) => {
let cycle = Self::extract_cycle(
&edge.target,
edge.edge_type,
stack,
stack_edges,
self.graph,
);
cycles.push(cycle);
}
Some(DfsColor::Black) => {}
}
}
stack.pop();
colors.insert(node_id.clone(), DfsColor::Black);
}
fn extract_cycle(
target: &NodeId,
closing_edge: EdgeType,
stack: &[NodeId],
stack_edges: &[EdgeType],
graph: &Graph,
) -> TraceCycle {
let idx = stack
.iter()
.position(|n| n == target)
.expect("gray target must be on the DFS stack");
let mut nodes: Vec<String> = stack[idx..]
.iter()
.filter_map(|id| graph.get_node(id).map(|n| n.name.clone()))
.collect();
if let Some(target_node) = graph.get_node(target) {
nodes.push(target_node.name.clone());
}
let mut edge_types: Vec<EdgeType> = stack_edges[idx..].to_vec();
edge_types.push(closing_edge);
TraceCycle { nodes, edge_types }
}
pub fn trace_cross_service(&self, start_id: &NodeId, depth: usize) -> Vec<TracePath> {
let Some(start_node) = self.graph.get_node(start_id) else {
return Vec::new();
};
let mut queue: VecDeque<WorkPath> = VecDeque::new();
queue.push_back(WorkPath {
visited_ids: vec![start_id.clone()],
path: TracePath {
nodes: vec![TraceNode::from(start_node)],
edges: Vec::new(),
depth: 0,
},
});
let mut results = Vec::new();
while let Some(work) = queue.pop_front() {
let has_edges = !work.path.edges.is_empty();
let can_extend = work.path.depth < depth;
if !can_extend {
if has_edges {
results.push(work.path);
}
continue;
}
let current_id = work
.visited_ids
.last()
.expect("work path always has at least one visited id")
.clone();
for edge in self.graph.edges_from(¤t_id) {
if !matches!(
edge.edge_type,
EdgeType::Calls | EdgeType::FfiCalls | EdgeType::HttpCalls
) {
continue;
}
let Some(target_node) = self.graph.get_node(&edge.target) else {
continue;
};
if work.visited_ids.contains(&edge.target) {
continue;
}
let mut new_visited = work.visited_ids.clone();
new_visited.push(edge.target.clone());
let mut new_path = work.path.clone();
new_path.nodes.push(TraceNode::from(target_node));
new_path.edges.push(TraceEdge {
edge_type: edge.edge_type.to_string(),
reason: edge.reason.clone(),
confidence: edge.confidence,
});
new_path.depth = work.path.depth + 1;
queue.push_back(WorkPath {
visited_ids: new_visited,
path: new_path,
});
}
for edge in self.graph.edges_to(¤t_id) {
if edge.edge_type != EdgeType::HandlesRoute {
continue;
}
let Some(handler_node) = self.graph.get_node(&edge.source) else {
continue;
};
if work.visited_ids.contains(&edge.source) {
continue;
}
let mut new_visited = work.visited_ids.clone();
new_visited.push(edge.source.clone());
let mut new_path = work.path.clone();
new_path.nodes.push(TraceNode::from(handler_node));
new_path.edges.push(TraceEdge {
edge_type: edge.edge_type.to_string(),
reason: edge.reason.clone(),
confidence: edge.confidence,
});
new_path.depth = work.path.depth + 1;
queue.push_back(WorkPath {
visited_ids: new_visited,
path: new_path,
});
}
if has_edges {
results.push(work.path);
}
}
results
}
}
#[derive(Clone, Copy, PartialEq)]
enum DfsColor {
Gray,
Black,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Edge, Node, NodeLabel};
fn make_func(id: &str, name: &str) -> Node {
Node::builder(NodeLabel::Function, name, format!("proj.{name}"))
.id(id)
.project("proj")
.file_path(format!("src/{name}.rs"))
.start_line(10)
.build()
}
fn make_func_no_loc(id: &str, name: &str) -> Node {
Node::builder(NodeLabel::Function, name, format!("proj.{name}"))
.id(id)
.project("proj")
.build()
}
fn graph_a_calls_b() -> Graph {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g
}
fn graph_a_calls_b_calls_c() -> Graph {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "c", EdgeType::Calls, "proj"));
g
}
#[test]
fn trace_a_returns_path_a_to_b() {
let g = graph_a_calls_b();
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert_eq!(paths.len(), 1);
let path = &paths[0];
assert_eq!(path.nodes.len(), 2);
assert_eq!(path.nodes[0].name, "a");
assert_eq!(path.nodes[1].name, "b");
assert_eq!(path.edges.len(), 1);
assert_eq!(path.edges[0].edge_type, "CALLS");
assert_eq!(path.depth, 1);
}
#[test]
fn trace_a_depth_2_returns_two_paths() {
let g = graph_a_calls_b_calls_c();
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 2);
assert_eq!(paths.len(), 2);
let depths: Vec<usize> = paths.iter().map(|p| p.depth).collect();
assert!(depths.contains(&1));
assert!(depths.contains(&2));
let deep = paths.iter().find(|p| p.depth == 2).unwrap();
assert_eq!(deep.nodes.len(), 3);
assert_eq!(deep.nodes[0].name, "a");
assert_eq!(deep.nodes[1].name, "b");
assert_eq!(deep.nodes[2].name, "c");
}
#[test]
fn trace_a_depth_1_returns_only_a_to_b() {
let g = graph_a_calls_b_calls_c();
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 1);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].depth, 1);
assert_eq!(paths[0].nodes.len(), 2);
assert_eq!(paths[0].nodes[1].name, "b");
}
#[test]
fn trace_respects_depth_limit() {
let g = graph_a_calls_b_calls_c();
let tracer = CallGraphTracer::new(&g);
let depth = 1;
let paths = tracer.trace(&"a".to_string(), depth);
for p in &paths {
assert!(p.depth <= depth);
}
}
#[test]
fn trace_ffi_calls_returns_path_with_ffi_edge() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(
Edge::builder("a", "b", EdgeType::FfiCalls, "proj")
.confidence(0.85)
.reason("extern \"C\" declaration match")
.build(),
);
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert_eq!(paths.len(), 1);
let path = &paths[0];
assert_eq!(path.edges.len(), 1);
assert_eq!(path.edges[0].edge_type, "FFI_CALLS");
assert!((path.edges[0].confidence - 0.85).abs() < f32::EPSILON);
assert_eq!(
path.edges[0].reason.as_deref(),
Some("extern \"C\" declaration match")
);
}
#[test]
fn trace_no_outgoing_calls_returns_empty() {
let g = graph_a_calls_b();
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"b".to_string(), 3);
assert!(paths.is_empty());
}
#[test]
fn trace_missing_start_node_returns_empty() {
let g = graph_a_calls_b();
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"missing".to_string(), 3);
assert!(paths.is_empty());
}
#[test]
fn trace_cyclic_calls_does_not_infinite_loop() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "a", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 5);
assert!(!paths.is_empty());
for p in &paths {
let mut names: Vec<&str> = p.nodes.iter().map(|n| n.name.as_str()).collect();
let len_before = names.len();
names.sort();
names.dedup();
assert_eq!(
names.len(),
len_before,
"path revisits a node: {:?}",
p.nodes
);
assert!(p.depth <= 5);
}
}
#[test]
fn trace_cyclic_returns_paths_within_depth() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "c", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("c", "a", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
for p in &paths {
assert!(p.depth <= 3);
}
assert!(paths.iter().any(|p| p.depth == 1));
assert!(paths.iter().any(|p| p.depth == 2));
}
#[test]
fn trace_node_includes_location_info() {
let g = graph_a_calls_b();
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
let path = &paths[0];
assert_eq!(path.nodes[0].file_path.as_deref(), Some("src/a.rs"));
assert_eq!(path.nodes[0].start_line, Some(10));
assert_eq!(path.nodes[0].label, "Function");
}
#[test]
fn trace_node_without_location_has_none() {
let mut g = Graph::new();
g.add_node(make_func_no_loc("a", "a"));
g.add_node(make_func_no_loc("b", "b"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert_eq!(paths.len(), 1);
assert!(paths[0].nodes[0].file_path.is_none());
assert!(paths[0].nodes[0].start_line.is_none());
}
#[test]
fn trace_zero_depth_returns_empty() {
let g = graph_a_calls_b();
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 0);
assert!(paths.is_empty());
}
#[test]
fn trace_skips_non_call_edges() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "b", EdgeType::Reads, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert!(paths.is_empty());
}
#[test]
fn trace_branching_graph_returns_all_paths() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_node(make_func("d", "d"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("a", "c", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "d", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("c", "d", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert_eq!(paths.len(), 4);
let depth2_count = paths.iter().filter(|p| p.depth == 2).count();
assert_eq!(depth2_count, 2);
let depth1_count = paths.iter().filter(|p| p.depth == 1).count();
assert_eq!(depth1_count, 2);
}
#[test]
fn trace_skips_edges_to_missing_nodes() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert!(paths.is_empty());
}
#[test]
fn trace_mixed_calls_and_ffi_calls() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "c", EdgeType::FfiCalls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert_eq!(paths.len(), 2);
let deep = paths.iter().find(|p| p.depth == 2).unwrap();
assert_eq!(deep.edges[1].edge_type, "FFI_CALLS");
}
#[test]
fn trace_self_loop_calls_returns_empty() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_edge(Edge::new("a", "a", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert!(
paths.is_empty(),
"self-loop should be skipped by cycle prevention"
);
}
#[test]
fn trace_depth_far_exceeding_graph_diameter_terminates() {
let g = graph_a_calls_b();
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 100);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].depth, 1);
}
#[test]
fn detect_cycles_abc_cycle_returns_trace_cycle() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "c", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("c", "a", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let cycles = tracer.detect_cycles();
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].nodes, vec!["a", "b", "c", "a"]);
assert_eq!(
cycles[0].edge_types,
vec![EdgeType::Calls, EdgeType::Calls, EdgeType::Calls]
);
}
#[test]
fn detect_cycles_no_cycle_returns_empty() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_node(make_func("d", "d"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "c", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("c", "d", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let cycles = tracer.detect_cycles();
assert!(cycles.is_empty());
}
#[test]
fn detect_cycles_self_loop_returns_aa_cycle() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_edge(Edge::new("a", "a", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let cycles = tracer.detect_cycles();
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].nodes, vec!["a", "a"]);
assert_eq!(cycles[0].edge_types, vec![EdgeType::Calls]);
}
#[test]
fn detect_cycles_multiple_cycles_returns_all() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_node(make_func("d", "d"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "a", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("c", "d", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("d", "c", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let cycles = tracer.detect_cycles();
assert_eq!(cycles.len(), 2);
let node_sets: Vec<Vec<String>> = cycles.iter().map(|c| c.nodes.clone()).collect();
assert!(node_sets.contains(&vec!["a".to_string(), "b".to_string(), "a".to_string()]));
assert!(node_sets.contains(&vec!["c".to_string(), "d".to_string(), "c".to_string()]));
}
#[test]
fn detect_cycles_empty_graph_returns_empty() {
let g = Graph::new();
let tracer = CallGraphTracer::new(&g);
assert!(tracer.detect_cycles().is_empty());
}
#[test]
fn detect_cycles_ffi_calls_edge_detected() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "b", EdgeType::FfiCalls, "proj"));
g.add_edge(Edge::new("b", "a", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let cycles = tracer.detect_cycles();
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].nodes, vec!["a", "b", "a"]);
assert_eq!(
cycles[0].edge_types,
vec![EdgeType::FfiCalls, EdgeType::Calls]
);
}
#[test]
fn detect_cycles_nested_cycle_detected() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "c", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("c", "b", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let cycles = tracer.detect_cycles();
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].nodes, vec!["b", "c", "b"]);
assert_eq!(cycles[0].edge_types, vec![EdgeType::Calls, EdgeType::Calls]);
}
#[test]
fn detect_cycles_skips_non_call_edges() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "b", EdgeType::Reads, "proj"));
g.add_edge(Edge::new("b", "a", EdgeType::Reads, "proj"));
let tracer = CallGraphTracer::new(&g);
assert!(tracer.detect_cycles().is_empty());
}
fn make_route(id: &str, name: &str) -> Node {
Node::builder(NodeLabel::Route, name, format!("proj.{name}"))
.id(id)
.project("proj")
.file_path(format!("routes/{name}.rs"))
.start_line(1)
.build()
}
#[test]
fn trace_cross_service_follows_http_calls_to_handler() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r", "/api/r"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "r", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("b", "r", EdgeType::HandlesRoute, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
let cross_path = paths
.iter()
.find(|p| p.depth == 2 && p.nodes.len() == 3)
.expect("should have A->R->B path");
assert_eq!(cross_path.nodes[0].name, "a");
assert_eq!(cross_path.nodes[1].name, "/api/r");
assert_eq!(cross_path.nodes[2].name, "b");
assert_eq!(cross_path.edges[0].edge_type, "HTTP_CALLS");
assert_eq!(cross_path.edges[1].edge_type, "HANDLES_ROUTE");
}
#[test]
fn trace_cross_service_includes_http_calls_edge_type() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r", "/api/r"));
g.add_edge(Edge::new("a", "r", EdgeType::HttpCalls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
let http_path = paths
.iter()
.find(|p| p.depth == 1)
.expect("should have A->R path");
assert_eq!(http_path.edges[0].edge_type, "HTTP_CALLS");
}
#[test]
fn trace_cross_service_still_follows_calls_edges() {
let g = graph_a_calls_b();
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 3);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].edges[0].edge_type, "CALLS");
}
#[test]
fn trace_cross_service_respects_depth_limit() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r", "/api/r"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "r", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("b", "r", EdgeType::HandlesRoute, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 1);
for p in &paths {
assert!(p.depth <= 1);
}
assert!(paths.iter().any(|p| p.depth == 1));
assert!(!paths.iter().any(|p| p.depth == 2));
}
#[test]
fn trace_regular_does_not_follow_http_calls() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r", "/api/r"));
g.add_edge(Edge::new("a", "r", EdgeType::HttpCalls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 5);
assert!(
paths.is_empty(),
"regular trace should not follow HttpCalls edges"
);
}
#[test]
fn trace_cross_service_missing_start_returns_empty() {
let g = Graph::new();
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"missing".to_string(), 5);
assert!(paths.is_empty());
}
#[test]
fn trace_cross_service_zero_depth_returns_empty() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r", "/api/r"));
g.add_edge(Edge::new("a", "r", EdgeType::HttpCalls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 0);
assert!(paths.is_empty());
}
#[test]
fn trace_cross_service_cycle_terminates() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r1", "/api/r1"));
g.add_node(make_func("b", "b"));
g.add_node(make_route("r2", "/api/r2"));
g.add_edge(Edge::new("a", "r1", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("b", "r1", EdgeType::HandlesRoute, "proj"));
g.add_edge(Edge::new("b", "r2", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("a", "r2", EdgeType::HandlesRoute, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 10);
for p in &paths {
assert!(p.depth <= 10);
}
assert!(!paths.is_empty());
}
#[test]
fn detect_cycles_diamond_graph_encounters_black_node() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_node(make_func("d", "d"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("a", "c", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "d", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("c", "d", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let cycles = tracer.detect_cycles();
assert!(cycles.is_empty(), "diamond has no cycle");
}
#[test]
fn trace_cross_service_skips_non_forward_dataflow_edge() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("v", "v"));
g.add_edge(Edge::new("a", "v", EdgeType::DataFlows, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
assert!(paths.is_empty(), "DataFlows edge should be skipped");
}
#[test]
fn trace_cross_service_skips_forward_edge_to_missing_target() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_edge(Edge::new("a", "missing", EdgeType::HttpCalls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
assert!(paths.is_empty(), "edge to missing target should be skipped");
}
#[test]
fn trace_cross_service_skips_forward_cycle_to_visited_node() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("r", "r"));
g.add_edge(Edge::new("a", "r", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("r", "a", EdgeType::HttpCalls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
for p in &paths {
let mut names: Vec<&str> = p.nodes.iter().map(|n| n.name.as_str()).collect();
let len_before = names.len();
names.sort();
names.dedup();
assert_eq!(
names.len(),
len_before,
"path revisits a node: {:?}",
p.nodes
);
}
assert!(paths.iter().any(|p| p.depth == 1));
}
#[test]
fn trace_cross_service_skips_non_handles_route_reverse_edge() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("b", "a", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
assert!(
paths.is_empty(),
"non-HandlesRoute reverse edge should be skipped"
);
}
#[test]
fn trace_cross_service_skips_reverse_edge_to_missing_handler() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_edge(Edge::new(
"missing_handler",
"a",
EdgeType::HandlesRoute,
"proj",
));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
assert!(
paths.is_empty(),
"reverse edge to missing handler should be skipped"
);
}
#[test]
fn trace_cross_service_skips_reverse_cycle_to_visited_node() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r", "/api/r"));
g.add_edge(Edge::new("a", "r", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("a", "r", EdgeType::HandlesRoute, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
for p in &paths {
let mut names: Vec<&str> = p.nodes.iter().map(|n| n.name.as_str()).collect();
let len_before = names.len();
names.sort();
names.dedup();
assert_eq!(
names.len(),
len_before,
"path revisits a node: {:?}",
p.nodes
);
}
assert!(paths.iter().any(|p| p.depth == 1));
}
#[test]
fn trace_cross_service_branching_http_calls() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r1", "/api/r1"));
g.add_node(make_route("r2", "/api/r2"));
g.add_node(make_func("b1", "b1"));
g.add_node(make_func("b2", "b2"));
g.add_edge(Edge::new("a", "r1", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("a", "r2", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("b1", "r1", EdgeType::HandlesRoute, "proj"));
g.add_edge(Edge::new("b2", "r2", EdgeType::HandlesRoute, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
let depth2: Vec<_> = paths.iter().filter(|p| p.depth == 2).collect();
assert_eq!(
depth2.len(),
2,
"expected two depth-2 branching paths, got {depth2:?}"
);
}
#[test]
fn detect_cycles_disconnected_graph_finds_all_cycles() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "a", EdgeType::Calls, "proj"));
g.add_node(make_func("c", "c"));
g.add_node(make_func("d", "d"));
g.add_edge(Edge::new("c", "d", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("d", "c", EdgeType::Calls, "proj"));
g.add_node(make_func("e", "e"));
g.add_node(make_func("f", "f"));
g.add_edge(Edge::new("e", "f", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let cycles = tracer.detect_cycles();
assert_eq!(
cycles.len(),
2,
"expected 2 cycles from disconnected subgraphs, got {cycles:?}"
);
}
#[test]
fn trace_cross_service_follows_ffi_calls_edges() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "b", EdgeType::FfiCalls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].edges[0].edge_type, "FFI_CALLS");
}
#[test]
fn trace_node_with_mixed_edge_types_only_follows_calls() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("a", "b", EdgeType::DataFlows, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].edges[0].edge_type, "CALLS");
}
#[test]
fn detect_cycles_ffi_self_loop_returns_cycle() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_edge(Edge::new("a", "a", EdgeType::FfiCalls, "proj"));
let tracer = CallGraphTracer::new(&g);
let cycles = tracer.detect_cycles();
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].nodes, vec!["a", "a"]);
assert_eq!(cycles[0].edge_types, vec![EdgeType::FfiCalls]);
}
#[test]
fn trace_cross_service_forward_and_reverse_to_different_nodes() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r1", "/api/r1"));
g.add_node(make_route("r2", "/api/r2"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "r1", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("b", "r2", EdgeType::HandlesRoute, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
assert!(paths
.iter()
.any(|p| p.depth == 1 && p.nodes[1].name == "/api/r1"));
assert!(!paths.iter().any(|p| p.nodes.iter().any(|n| n.name == "b")));
}
#[test]
fn trace_empty_graph_returns_empty() {
let g = Graph::new();
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"any".to_string(), 5);
assert!(paths.is_empty());
}
#[test]
fn detect_cycles_single_node_no_edges_returns_empty() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
let tracer = CallGraphTracer::new(&g);
assert!(tracer.detect_cycles().is_empty());
}
#[test]
fn trace_cross_service_no_outgoing_edges_returns_empty() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
assert!(
paths.is_empty(),
"node with no outgoing edges should return empty: {paths:?}"
);
}
#[test]
fn trace_cross_service_both_forward_and_reverse_from_same_node() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r", "/api/r"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "r", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("b", "a", EdgeType::HandlesRoute, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
let forward = paths
.iter()
.find(|p| p.depth == 1 && p.nodes.len() == 2 && p.nodes[1].name == "/api/r");
assert!(forward.is_some(), "should find A→R forward path: {paths:?}");
assert_eq!(forward.unwrap().edges[0].edge_type, "HTTP_CALLS");
let reverse = paths
.iter()
.find(|p| p.depth == 1 && p.nodes.len() == 2 && p.nodes[1].name == "b");
assert!(reverse.is_some(), "should find A→B reverse path: {paths:?}");
assert_eq!(reverse.unwrap().edges[0].edge_type, "HANDLES_ROUTE");
}
#[test]
fn trace_mid_path_node_with_only_non_call_edges_records_prefix() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "c", EdgeType::Reads, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert_eq!(paths.len(), 1, "should record A→B prefix only");
assert_eq!(paths[0].depth, 1);
assert_eq!(paths[0].nodes.len(), 2);
}
#[test]
fn trace_mid_path_node_with_only_edges_to_missing_targets_records_prefix() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "missing", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert_eq!(paths.len(), 1, "should record A→B prefix");
assert_eq!(paths[0].depth, 1);
}
#[test]
fn trace_cross_service_mid_path_node_with_no_valid_edges_records_prefix() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r", "/api/r"));
g.add_node(make_func("c", "c"));
g.add_edge(Edge::new("a", "r", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("r", "c", EdgeType::Reads, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
assert!(
paths
.iter()
.any(|p| p.depth == 1 && p.nodes[1].name == "/api/r"),
"should record A→R prefix: {paths:?}"
);
}
#[test]
fn trace_parallel_edges_same_target_different_types_produces_two_paths() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("a", "b", EdgeType::FfiCalls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert_eq!(paths.len(), 2, "parallel edges should produce 2 paths");
let edge_types: Vec<&str> = paths
.iter()
.map(|p| p.edges[0].edge_type.as_str())
.collect();
assert!(edge_types.contains(&"CALLS"));
assert!(edge_types.contains(&"FFI_CALLS"));
}
#[test]
fn detect_cycles_parallel_edges_no_false_cycle() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("a", "b", EdgeType::FfiCalls, "proj"));
let tracer = CallGraphTracer::new(&g);
let cycles = tracer.detect_cycles();
assert!(
cycles.is_empty(),
"parallel edges without back-edge should not create a cycle"
);
}
#[test]
fn trace_cross_service_depth_exactly_2_includes_depth_2_paths() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r", "/api/r"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "r", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("b", "r", EdgeType::HandlesRoute, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 2);
assert!(
paths.iter().any(|p| p.depth == 1),
"should include depth-1 path"
);
assert!(
paths.iter().any(|p| p.depth == 2),
"should include depth-2 path"
);
}
#[test]
fn detect_cycles_larger_cycle_with_mixed_edge_types() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_node(make_func("d", "d"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "c", EdgeType::FfiCalls, "proj"));
g.add_edge(Edge::new("c", "d", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("d", "a", EdgeType::FfiCalls, "proj"));
let tracer = CallGraphTracer::new(&g);
let cycles = tracer.detect_cycles();
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].nodes, vec!["a", "b", "c", "d", "a"]);
assert_eq!(
cycles[0].edge_types,
vec![
EdgeType::Calls,
EdgeType::FfiCalls,
EdgeType::Calls,
EdgeType::FfiCalls
]
);
}
#[test]
fn trace_branching_with_different_depths_records_all_prefixes() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_func("b", "b"));
g.add_node(make_func("c", "c"));
g.add_node(make_func("d", "d"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("a", "c", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "d", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace(&"a".to_string(), 3);
assert_eq!(paths.len(), 3, "should record A→B, A→C, A→B→D");
let depths: Vec<usize> = paths.iter().map(|p| p.depth).collect();
assert!(depths.contains(&1));
assert!(depths.contains(&2));
}
#[test]
fn detect_cycles_cycle_through_nonexistent_intermediate_node() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_edge(Edge::new("a", "b", EdgeType::Calls, "proj"));
g.add_edge(Edge::new("b", "a", EdgeType::Calls, "proj"));
let tracer = CallGraphTracer::new(&g);
let cycles = tracer.detect_cycles();
assert_eq!(cycles.len(), 1, "should detect cycle via back-edge B→A");
assert_eq!(
cycles[0].nodes,
vec!["a".to_string(), "a".to_string()],
"nonexistent B should be filtered out by extract_cycle"
);
assert_eq!(cycles[0].edge_types, vec![EdgeType::Calls, EdgeType::Calls]);
}
#[test]
fn trace_cross_service_mid_path_node_with_both_forward_and_reverse_edges() {
let mut g = Graph::new();
g.add_node(make_func("a", "a"));
g.add_node(make_route("r1", "/api/r1"));
g.add_node(make_route("r2", "/api/r2"));
g.add_node(make_func("b", "b"));
g.add_edge(Edge::new("a", "r1", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("r1", "r2", EdgeType::HttpCalls, "proj"));
g.add_edge(Edge::new("b", "r1", EdgeType::HandlesRoute, "proj"));
let tracer = CallGraphTracer::new(&g);
let paths = tracer.trace_cross_service(&"a".to_string(), 5);
let depth2: Vec<_> = paths.iter().filter(|p| p.depth == 2).collect();
assert_eq!(
depth2.len(),
2,
"should have two depth-2 paths (forward to R2, reverse to B): {paths:?}"
);
let has_r2 = depth2
.iter()
.any(|p| p.nodes.iter().any(|n| n.name == "/api/r2"));
let has_b = depth2.iter().any(|p| p.nodes.iter().any(|n| n.name == "b"));
assert!(
has_r2,
"should reach R2 via forward HttpCalls from mid-path R1: {paths:?}"
);
assert!(
has_b,
"should reach B via reverse HandlesRoute from mid-path R1: {paths:?}"
);
}
}