use crate::{Edge, GraphError, Node, NodeId, Result, Topology};
use crate::{ToString, Vec};
#[cfg(not(feature = "std"))]
use alloc::collections::BTreeMap as NodeMap;
#[cfg(feature = "std")]
use std::collections::HashMap as NodeMap;
pub(super) fn canonicalize_edges(
nodes: &[Node],
edges: Vec<Edge>,
) -> Result<(Vec<Edge>, Topology)> {
let positions = node_positions(nodes);
let count = edges.len();
let mut slots = Vec::with_capacity(count);
let mut targets = Vec::with_capacity(count);
let mut sources = Vec::with_capacity(count);
let mut starts = vec![0_usize; nodes.len() + 1];
for edge in edges {
let source = position(&positions, &edge.source, true)?;
let target = position(&positions, &edge.target, false)?;
sources.push(source);
targets.push(target);
starts[source + 1] += 1;
slots.push(Some(edge));
}
for index in 0..nodes.len() {
starts[index + 1] += starts[index];
}
let mut order = vec![0_usize; count];
let mut cursors = starts.clone();
for (index, source) in sources.into_iter().enumerate() {
order[cursors[source]] = index;
cursors[source] += 1;
}
let mut canonical = Vec::with_capacity(count);
let mut endpoints = Vec::with_capacity(count);
let mut kept = Vec::new();
for source in 0..nodes.len() {
let group = &mut order[starts[source]..starts[source + 1]];
if group.is_empty() {
continue;
}
group.sort_unstable_by(|left, right| slots[*left].cmp(&slots[*right]));
kept.clear();
for index in group.iter().copied() {
if kept
.last()
.is_some_and(|previous: &usize| slots[*previous] == slots[index])
{
continue;
}
kept.push(index);
}
for index in kept.iter().copied() {
let Some(edge) = slots[index].take() else {
continue;
};
canonical.push(edge);
endpoints.push((source, targets[index]));
}
}
let topology = Topology::try_from_usize_edges(nodes.len(), endpoints)?;
Ok((canonical, topology))
}
pub(super) fn index_canonical_edges(nodes: &[Node], edges: &[Edge]) -> Result<Topology> {
let positions = node_positions(nodes);
let mut endpoints = Vec::with_capacity(edges.len());
for edge in edges {
let source = position(&positions, &edge.source, true)?;
let target = position(&positions, &edge.target, false)?;
endpoints.push((source, target));
}
Topology::try_from_usize_edges(nodes.len(), endpoints)
}
pub(super) fn index_sorted_edges(nodes: &[Node], edges: &[Edge]) -> Result<Topology> {
let positions = node_positions(nodes);
let mut endpoints = Vec::with_capacity(edges.len());
let mut source_cursor = 0;
for edge in edges {
while source_cursor < nodes.len() && nodes[source_cursor].id < edge.source {
source_cursor += 1;
}
if source_cursor == nodes.len() || nodes[source_cursor].id != edge.source {
return Err(GraphError::MissingEdgeSource {
id: edge.source.to_string(),
});
}
let target = position(&positions, &edge.target, false)?;
endpoints.push((source_cursor, target));
}
Topology::try_from_usize_edges(nodes.len(), endpoints)
}
fn node_positions(nodes: &[Node]) -> NodeMap<&NodeId, usize> {
nodes
.iter()
.enumerate()
.map(|(index, node)| (&node.id, index))
.collect()
}
fn position(positions: &NodeMap<&NodeId, usize>, id: &NodeId, source: bool) -> Result<usize> {
positions.get(id).copied().ok_or_else(|| {
if source {
GraphError::MissingEdgeSource { id: id.to_string() }
} else {
GraphError::MissingEdgeTarget { id: id.to_string() }
}
})
}
#[cfg(test)]
mod tests {
use crate::{
Confidence, Edge, EdgeKind, EvidenceKind, Graph, Node, NodeId, NodeKind, Provenance,
};
fn node(id: &str) -> Node {
Node::new(id, id, NodeKind::File).unwrap()
}
fn edge(source: &str, target: &str, kind: EdgeKind, extractor: &str) -> Edge {
Edge::new(
NodeId::new(source).unwrap(),
NodeId::new(target).unwrap(),
kind,
Provenance::new(extractor, EvidenceKind::Parsed, Confidence::High).unwrap(),
)
}
#[test]
fn canonical_order_matches_the_pre_sorted_path() {
let nodes = ["a", "b", "c"].map(node).to_vec();
let scrambled = vec![
edge("c", "a", EdgeKind::Calls, "second"),
edge("a", "c", EdgeKind::Imports, "first"),
edge("b", "a", EdgeKind::Calls, "first"),
edge("a", "b", EdgeKind::Calls, "first"),
edge("c", "a", EdgeKind::Calls, "first"),
edge("a", "b", EdgeKind::Calls, "first"),
edge("a", "b", EdgeKind::Calls, "second"),
];
let canonical = Graph::try_from_parts(nodes.clone(), scrambled.clone()).unwrap();
let mut expected = scrambled;
expected.sort();
expected.dedup();
let sorted = Graph::try_from_sorted_parts(nodes, expected).unwrap();
assert_eq!(
canonical.edges(),
sorted.edges(),
"canonicalization must agree with the already-sorted path"
);
assert_eq!(canonical.edge_count(), 6, "the duplicate edge is dropped");
assert_eq!(
canonical.edges().first().map(|edge| edge.source.as_str()),
Some("a"),
"edges are grouped by source position"
);
}
#[test]
fn dangling_endpoints_are_reported_by_side() {
let nodes = vec![node("a")];
let missing_source =
Graph::try_from_parts(nodes.clone(), vec![edge("z", "a", EdgeKind::Calls, "x")]);
assert!(
format!("{:?}", missing_source.unwrap_err()).contains("MissingEdgeSource"),
"an unknown source is reported as a missing source"
);
let missing_target =
Graph::try_from_parts(nodes, vec![edge("a", "z", EdgeKind::Calls, "x")]);
assert!(
format!("{:?}", missing_target.unwrap_err()).contains("MissingEdgeTarget"),
"an unknown target is reported as a missing target"
);
}
}