use std::collections::HashMap;
use petgraph::graph::NodeIndex;
use super::{Edge, GraphDb, Node};
#[cfg(all(feature = "ast-context", feature = "graph"))]
use aptu_coder_core::{SemanticAnalysis, graph::CallGraph};
#[cfg(all(feature = "ast-context", feature = "graph"))]
#[must_use]
pub fn build_from_analysis(
path: &str,
semantic: &SemanticAnalysis,
call_graph: &CallGraph,
) -> GraphDb {
let mut graph = GraphDb::new();
let file_idx = graph.add_node(Node::File {
name: path.rsplit('/').next().unwrap_or(path).to_string(),
path: path.to_string(),
});
let mut fn_name_to_idx: HashMap<String, NodeIndex> = HashMap::new();
for func in &semantic.functions {
let fn_idx = graph.add_node(Node::Function {
name: func.name.clone(),
path: path.to_string(),
visibility: "private".to_string(),
});
graph.add_edge(file_idx, fn_idx, Edge::Contains);
fn_name_to_idx.insert(func.name.clone(), fn_idx);
}
for imp in &semantic.imports {
let module_idx = graph.add_node(Node::Module {
name: imp.module.clone(),
path: String::new(),
});
graph.add_edge(file_idx, module_idx, Edge::Imports);
}
for (callee_name, call_edges) in &call_graph.callers {
let dst = fn_name_to_idx.get(callee_name).copied().unwrap_or_else(|| {
let idx = graph.add_node(Node::Function {
name: callee_name.clone(),
path: String::new(),
visibility: "private".to_string(),
});
fn_name_to_idx.insert(callee_name.clone(), idx);
idx
});
for call_edge in call_edges {
if call_edge.neighbor_name == "<reference>" {
continue;
}
if call_edge.is_impl_trait {
continue;
}
let raw_path = call_edge.path.to_string_lossy().into_owned();
let caller_path = if raw_path.is_empty() || raw_path == "." {
String::new()
} else {
raw_path
};
let src = fn_name_to_idx
.get(&call_edge.neighbor_name)
.copied()
.unwrap_or_else(|| {
let idx = graph.add_node(Node::Function {
name: call_edge.neighbor_name.clone(),
path: caller_path,
visibility: "private".to_string(),
});
fn_name_to_idx.insert(call_edge.neighbor_name.clone(), idx);
idx
});
graph.add_edge(src, dst, Edge::Calls);
}
}
graph
}
#[cfg(test)]
mod tests {
use super::*;
use aptu_coder_core::graph::CallGraph;
use aptu_coder_core::{CallEdge, FunctionInfo, ImportInfo, SemanticAnalysis};
use std::collections::HashMap;
use std::path::PathBuf;
fn make_fn(name: &str) -> FunctionInfo {
let mut f = FunctionInfo::default();
f.name = name.to_string();
f
}
fn make_fn_with_params(name: &str, params: Vec<&str>, ret: Option<&str>) -> FunctionInfo {
let mut f = FunctionInfo::default();
f.name = name.to_string();
f.line = 1;
f.end_line = 10;
f.parameters = params.into_iter().map(str::to_string).collect();
f.return_type = ret.map(str::to_string);
f
}
fn make_semantic(functions: Vec<FunctionInfo>, imports: Vec<ImportInfo>) -> SemanticAnalysis {
SemanticAnalysis::new(
functions,
vec![],
imports,
vec![],
HashMap::new(),
vec![],
vec![],
)
}
fn make_call_graph(callers: HashMap<String, Vec<CallEdge>>) -> CallGraph {
let mut cg = CallGraph::new();
cg.callers = callers;
cg
}
#[test]
fn test_build_from_analysis_emits_file_function_contains() {
let semantic = make_semantic(
vec![make_fn_with_params(
"apply_changes",
vec!["repo: &Repo"],
Some("Result<()>"),
)],
vec![],
);
let call_graph = make_call_graph(HashMap::new());
let graph = build_from_analysis("src/lib.rs", &semantic, &call_graph);
let file_count = graph
.node_weights()
.filter(|n| matches!(n, Node::File { .. }))
.count();
assert_eq!(file_count, 1, "expected one File node");
let fn_names: Vec<&str> = graph
.node_weights()
.filter_map(|n| match n {
Node::Function { name, .. } => Some(name.as_str()),
_ => None,
})
.collect();
assert!(
fn_names.contains(&"apply_changes"),
"expected function node 'apply_changes'; got {fn_names:?}"
);
let contains_count = graph
.edge_indices()
.filter(|&e| matches!(graph.edge_weight(e), Some(Edge::Contains)))
.count();
assert_eq!(contains_count, 1, "expected one Contains edge");
}
#[test]
fn test_build_from_analysis_filters_reference_edges() {
let semantic = make_semantic(vec![make_fn("target_fn")], vec![]);
let mut callers: HashMap<String, Vec<CallEdge>> = HashMap::new();
callers.insert(
"target_fn".to_string(),
vec![
CallEdge {
neighbor_name: "real_caller".to_string(),
path: PathBuf::from("src/caller.rs"),
line: 10,
is_impl_trait: false,
},
CallEdge {
neighbor_name: "<reference>".to_string(),
path: PathBuf::from("src/other.rs"),
line: 20,
is_impl_trait: false,
},
],
);
let call_graph = make_call_graph(callers);
let graph = build_from_analysis("src/lib.rs", &semantic, &call_graph);
let calls_count = graph
.edge_indices()
.filter(|&e| matches!(graph.edge_weight(e), Some(Edge::Calls)))
.count();
assert_eq!(
calls_count, 1,
"expected one Calls edge; <reference> must be filtered"
);
let caller_names: Vec<&str> = graph
.node_weights()
.filter_map(|n| match n {
Node::Function { name, .. } if name != "target_fn" => Some(name.as_str()),
_ => None,
})
.collect();
assert!(
caller_names.contains(&"real_caller"),
"expected 'real_caller' node"
);
assert!(
!caller_names.contains(&"<reference>"),
"<reference> must not appear as a node"
);
}
#[test]
fn test_build_from_analysis_empty_produces_empty_graph() {
let semantic = make_semantic(vec![], vec![]);
let call_graph = make_call_graph(HashMap::new());
let graph = build_from_analysis("src/empty.rs", &semantic, &call_graph);
assert_eq!(graph.node_count(), 1, "expected only the File node");
let file_count = graph
.node_weights()
.filter(|n| matches!(n, Node::File { .. }))
.count();
assert_eq!(file_count, 1, "expected one File node");
assert_eq!(graph.edge_count(), 0, "expected no edges");
}
#[test]
fn test_build_from_analysis_defaults_visibility_to_private() {
let semantic = make_semantic(vec![make_fn("helper")], vec![]);
let call_graph = make_call_graph(HashMap::new());
let graph = build_from_analysis("src/lib.rs", &semantic, &call_graph);
let visibilities: Vec<&str> = graph
.node_weights()
.filter_map(|n| match n {
Node::Function { visibility, .. } => Some(visibility.as_str()),
_ => None,
})
.collect();
assert_eq!(visibilities.len(), 1, "expected one function node");
assert_eq!(
visibilities[0], "private",
"visibility should default to 'private'"
);
}
}