use std::collections::{HashSet, VecDeque};
use petgraph::Direction;
use petgraph::graph::NodeIndex;
use petgraph::visit::EdgeRef as _;
use super::{Edge, GraphDb, Node};
#[must_use]
pub fn find_modified_nodes(graph: &mut GraphDb, modified_symbols: &[&str]) -> Vec<NodeIndex> {
let symbol_set: HashSet<&str> = modified_symbols.iter().copied().collect();
let matched: Vec<NodeIndex> = graph
.node_indices()
.filter(|&idx| {
let name = graph[idx].name();
symbol_set.contains(name)
})
.collect();
matched
}
#[must_use]
pub fn blast_radius(graph: &GraphDb, modified_nodes: &[NodeIndex], max_nodes: usize) -> GraphDb {
if modified_nodes.is_empty() || max_nodes == 0 {
return GraphDb::new();
}
let relevant_edges = |e: &Edge| {
matches!(
e,
Edge::Calls | Edge::Implements | Edge::HasMethod | Edge::Tests
)
};
let mut visited: HashSet<NodeIndex> = HashSet::new();
let mut queue: VecDeque<NodeIndex> = VecDeque::new();
for &node in modified_nodes {
if graph.node_weight(node).is_some() && visited.insert(node) {
queue.push_back(node);
}
}
while let Some(current) = queue.pop_front() {
if visited.len() >= max_nodes {
break;
}
for edge_ref in graph.edges_directed(current, Direction::Outgoing) {
if relevant_edges(edge_ref.weight()) {
let target = edge_ref.target();
if visited.len() < max_nodes && visited.insert(target) {
queue.push_back(target);
}
}
}
for edge_ref in graph.edges_directed(current, Direction::Incoming) {
if relevant_edges(edge_ref.weight()) {
let source = edge_ref.source();
if visited.len() < max_nodes && visited.insert(source) {
queue.push_back(source);
}
}
}
}
build_induced_subgraph(graph, &visited)
}
fn build_induced_subgraph(graph: &GraphDb, node_set: &HashSet<NodeIndex>) -> GraphDb {
use std::collections::HashMap;
let mut sub = GraphDb::new();
let mut index_map: HashMap<NodeIndex, NodeIndex> = HashMap::new();
for &old_idx in node_set {
if let Some(weight) = graph.node_weight(old_idx) {
let new_idx = sub.add_node(weight.clone());
index_map.insert(old_idx, new_idx);
}
}
for edge_ref in graph.edge_references() {
let src = edge_ref.source();
let dst = edge_ref.target();
let weight = edge_ref.weight();
if matches!(weight, Edge::Modifies | Edge::Contains) {
continue;
}
if let (Some(&new_src), Some(&new_dst)) = (index_map.get(&src), index_map.get(&dst)) {
sub.add_edge(new_src, new_dst, *weight);
}
}
sub
}
#[must_use]
pub fn render_subgraph_text(subgraph: &GraphDb) -> String {
use std::collections::{BTreeMap, HashMap};
let mut calls: HashMap<NodeIndex, Vec<String>> = HashMap::new();
let mut callers: HashMap<NodeIndex, Vec<String>> = HashMap::new();
for edge_ref in subgraph.edge_references() {
if matches!(edge_ref.weight(), Edge::Calls) {
calls
.entry(edge_ref.source())
.or_default()
.push(subgraph[edge_ref.target()].name().to_string());
callers
.entry(edge_ref.target())
.or_default()
.push(subgraph[edge_ref.source()].name().to_string());
}
if matches!(edge_ref.weight(), Edge::HasMethod | Edge::Implements) {
calls
.entry(edge_ref.source())
.or_default()
.push(subgraph[edge_ref.target()].name().to_string());
}
}
let mut by_file: BTreeMap<String, Vec<String>> = BTreeMap::new();
for idx in subgraph.node_indices() {
let node = &subgraph[idx];
let prefix = match node {
Node::Function { .. } => "fn",
Node::Struct { .. } => "struct",
Node::Enum { .. } => "enum",
Node::Trait { .. } => "trait",
Node::Impl { .. } => "impl",
Node::File { .. } | Node::Module { .. } => continue,
};
let name = node.name();
let mut parts = vec![format!("{prefix} {name}")];
if let Some(c) = calls.get(&idx) {
let mut sorted = c.clone();
sorted.sort();
parts.push(format!("[calls: {}]", sorted.join(", ")));
}
if let Some(c) = callers.get(&idx) {
let mut sorted = c.clone();
sorted.sort();
parts.push(format!("[callers: {}]", sorted.join(", ")));
}
by_file
.entry(node.path().to_string())
.or_default()
.push(parts.join(" "));
}
let mut out = String::new();
for (path, mut lines) in by_file {
if !out.is_empty() {
out.push('\n');
}
out.push_str("// ");
out.push_str(&path);
out.push('\n');
lines.sort();
out.push_str(&lines.join("\n"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn two_caller_graph() -> (GraphDb, NodeIndex, NodeIndex, NodeIndex) {
let mut graph = GraphDb::new();
let target = graph.add_node(Node::Function {
name: "target".to_string(),
path: "src/lib.rs".to_string(),
visibility: "pub".to_string(),
});
let caller_a = graph.add_node(Node::Function {
name: "caller_a".to_string(),
path: "src/lib.rs".to_string(),
visibility: "private".to_string(),
});
let caller_b = graph.add_node(Node::Function {
name: "caller_b".to_string(),
path: "src/lib.rs".to_string(),
visibility: "private".to_string(),
});
graph.add_edge(caller_a, target, Edge::Calls);
graph.add_edge(caller_b, target, Edge::Calls);
(graph, target, caller_a, caller_b)
}
#[test]
fn test_blast_radius_returns_all_direct_callers_of_modified_node() {
let (graph, target, caller_a, caller_b) = two_caller_graph();
let sub = blast_radius(&graph, &[target], 100);
let names: Vec<&str> = sub.node_weights().map(|n| n.name()).collect();
assert!(names.contains(&"target"), "target must be in subgraph");
assert!(names.contains(&"caller_a"), "caller_a must be in subgraph");
assert!(names.contains(&"caller_b"), "caller_b must be in subgraph");
}
#[test]
fn test_blast_radius_bounded_by_max_nodes() {
let mut graph = GraphDb::new();
let mut prev = graph.add_node(Node::Function {
name: "n0".to_string(),
path: "".to_string(),
visibility: "pub".to_string(),
});
let root = prev;
for i in 1..10usize {
let next = graph.add_node(Node::Function {
name: format!("n{i}"),
path: "".to_string(),
visibility: "pub".to_string(),
});
graph.add_edge(prev, next, Edge::Calls);
prev = next;
}
let sub = blast_radius(&graph, &[root], 3);
assert!(
sub.node_count() <= 3,
"blast_radius must respect max_nodes cap; got {}",
sub.node_count()
);
}
#[test]
fn test_blast_radius_empty_when_max_nodes_zero() {
let (graph, target, _, _) = two_caller_graph();
let sub = blast_radius(&graph, &[target], 0);
assert_eq!(sub.node_count(), 0);
}
#[test]
fn test_render_subgraph_text_contains_function_with_caller() {
let (graph, target, _caller_a, _caller_b) = two_caller_graph();
let sub = blast_radius(&graph, &[target], 100);
let text = render_subgraph_text(&sub);
assert!(text.contains("fn target"), "must render target function");
assert!(text.contains("[callers:"), "must render callers annotation");
}
#[test]
fn test_find_modified_nodes_matches_named_nodes() {
let mut graph = GraphDb::new();
graph.add_node(Node::Function {
name: "foo".to_string(),
path: "".to_string(),
visibility: "pub".to_string(),
});
graph.add_node(Node::Function {
name: "bar".to_string(),
path: "".to_string(),
visibility: "pub".to_string(),
});
let matched = find_modified_nodes(&mut graph, &["foo"]);
assert_eq!(matched.len(), 1);
assert_eq!(graph.edge_count(), 0, "no sentinel edges should be added");
}
}