pub mod api;
pub mod artifact;
pub mod callgraph;
pub mod callgraph_dwarf;
pub mod callgraph_llvm;
pub mod depgraph;
pub mod persist;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub from: String,
pub to: String,
pub kind: EdgeKind,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum EdgeKind {
Calls,
DependsOn,
Reexports,
Implements,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Graph {
pub nodes: Vec<String>,
pub edges: Vec<Edge>,
}
impl Graph {
pub fn to_svg(&self) -> String {
render_svg(&self.nodes, &self.edges)
}
pub fn to_text(&self) -> String {
let mut s = String::new();
s.push_str("nodes:\n");
for n in &self.nodes {
s.push_str(" ");
s.push_str(n);
s.push('\n');
}
if self.edges.is_empty() {
s.push_str("edges: (none)\n");
} else {
s.push_str("edges:\n");
for e in &self.edges {
let kind = match e.kind {
EdgeKind::Calls => "calls",
EdgeKind::DependsOn => "depends-on",
EdgeKind::Reexports => "reexports",
EdgeKind::Implements => "implements",
};
s.push_str(&format!(" {} → {} ({kind})\n", e.from, e.to));
}
}
s
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn svg_layers(nodes: &[String], edges: &[Edge]) -> Vec<Vec<usize>> {
use std::collections::{HashMap, HashSet};
let n = nodes.len();
let idx: HashMap<&str, usize> =
nodes.iter().enumerate().map(|(i, s)| (s.as_str(), i)).collect();
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut indeg: Vec<usize> = vec![0; n];
for e in edges {
if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
if f != t {
adj[f].push(t);
indeg[t] += 1;
}
}
}
let mut layer_of = vec![0usize; n];
let mut remaining: HashSet<usize> = (0..n).collect();
let mut level = 0usize;
while !remaining.is_empty() {
let ready: Vec<usize> =
remaining.iter().copied().filter(|&i| indeg[i] == 0).collect();
if ready.is_empty() {
for &i in &remaining {
layer_of[i] = level;
}
break;
}
for &i in &ready {
layer_of[i] = level;
remaining.remove(&i);
}
for &i in &ready {
for &j in &adj[i] {
if indeg[j] > 0 {
indeg[j] -= 1;
}
}
}
level += 1;
}
let max_level = *layer_of.iter().max().unwrap_or(&0);
let mut layers: Vec<Vec<usize>> = vec![Vec::new(); max_level + 1];
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| nodes[a].cmp(&nodes[b]));
for i in order {
layers[layer_of[i]].push(i);
}
layers
}
pub(crate) fn render_svg(nodes: &[String], edges: &[Edge]) -> String {
use std::collections::HashMap;
if nodes.is_empty() {
return String::from(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"160\" height=\"40\">\
<text x=\"8\" y=\"24\" font-family=\"sans-serif\" font-size=\"12\">(empty graph)</text></svg>\n",
);
}
let layers = svg_layers(nodes, edges);
let col_w = 180.0f64;
let row_h = 40.0f64;
let box_w = 150.0f64;
let box_h = 24.0f64;
let margin = 14.0f64;
let cols = layers.len().max(1);
let rows = layers.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
let width = margin * 2.0 + col_w * cols as f64;
let height = margin * 2.0 + row_h * rows as f64;
let mut pos: HashMap<usize, (f64, f64)> = HashMap::new();
for (ci, layer) in layers.iter().enumerate() {
let total_h = layer.len() as f64 * row_h;
let start_y = margin + (height - margin * 2.0 - total_h) / 2.0;
for (ri, &node_i) in layer.iter().enumerate() {
let x = margin + ci as f64 * col_w;
let y = start_y + ri as f64 * row_h;
pos.insert(node_i, (x, y));
}
}
let name_to_idx: HashMap<&str, usize> =
nodes.iter().enumerate().map(|(i, s)| (s.as_str(), i)).collect();
let mut s = String::new();
s.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.0}\" height=\"{height:.0}\" \
viewBox=\"0 0 {width:.0} {height:.0}\" font-family=\"sans-serif\" font-size=\"11\">\n"
));
s.push_str(
"<defs><marker id=\"arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"7\" refY=\"3\" \
orient=\"auto\"><path d=\"M0,0 L7,3 L0,6 Z\" fill=\"#5a6876\"/></marker></defs>\n",
);
for e in edges {
let (Some(&fi), Some(&ti)) =
(name_to_idx.get(e.from.as_str()), name_to_idx.get(e.to.as_str()))
else {
continue;
};
let (Some(&(fx, fy)), Some(&(tx, ty))) = (pos.get(&fi), pos.get(&ti)) else {
continue;
};
let x1 = fx + box_w;
let y1 = fy + box_h / 2.0;
let x2 = tx;
let y2 = ty + box_h / 2.0;
let dash = match e.kind {
EdgeKind::DependsOn => " stroke-dasharray=\"4 3\"",
_ => "",
};
let color = match e.kind {
EdgeKind::Calls => "#2850a0",
EdgeKind::DependsOn => "#787882",
EdgeKind::Reexports => "#28823c",
EdgeKind::Implements => "#a05028",
};
s.push_str(&format!(
"<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
stroke=\"{color}\" stroke-width=\"1\"{dash} marker-end=\"url(#arrow)\"/>\n"
));
}
for (i, name) in nodes.iter().enumerate() {
let Some(&(x, y)) = pos.get(&i) else { continue };
let label = xml_escape(name);
s.push_str(&format!(
"<rect x=\"{x:.0}\" y=\"{y:.0}\" width=\"{box_w:.0}\" height=\"{box_h:.0}\" rx=\"3\" \
fill=\"#f5f7fc\" stroke=\"#3c3c50\" stroke-width=\"0.7\"/>\n"
));
s.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\">{label}</text>\n",
x + box_w / 2.0,
y + box_h / 2.0 + 4.0,
));
}
s.push_str("</svg>\n");
s
}
#[cfg(test)]
mod tests {
use super::*;
fn g() -> Graph {
Graph {
nodes: vec!["a-1".into(), "b".into(), "c".into()],
edges: vec![
Edge { from: "a-1".into(), to: "b".into(), kind: EdgeKind::DependsOn },
Edge { from: "b".into(), to: "c".into(), kind: EdgeKind::Calls },
],
}
}
#[test]
fn to_svg_is_self_contained_and_has_nodes() {
let svg = g().to_svg();
assert!(svg.starts_with("<svg"), "{svg}");
assert!(svg.contains("</svg>"));
assert!(!svg.contains("mermaid"), "{svg}");
assert!(svg.contains(">a-1<"), "node label a-1: {svg}");
assert!(svg.contains(">c<"), "node label c: {svg}");
assert!(svg.contains("<line "), "edges: {svg}");
}
#[test]
fn to_svg_empty_graph_placeholder() {
let svg = Graph::default().to_svg();
assert!(svg.starts_with("<svg"), "{svg}");
assert!(svg.contains("empty graph"), "{svg}");
}
#[test]
fn to_text_lists_nodes_and_edges() {
let t = g().to_text();
assert!(t.contains("a-1\n"), "{t}");
assert!(t.contains("a-1 → b (depends-on)"), "{t}");
assert!(t.contains("b → c (calls)"), "{t}");
assert!(!t.contains("mermaid"));
}
}