pub mod warehouse;
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::knowledge::symbols::{CallEdgeRow, SymbolRow};
use crate::warehouse::access_scan::{Access, AccessEdge};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum NodeKind {
Component,
Grpc,
CoreFn,
Table,
}
impl NodeKind {
pub fn as_str(self) -> &'static str {
match self {
NodeKind::Component => "component",
NodeKind::Grpc => "grpc",
NodeKind::CoreFn => "corefn",
NodeKind::Table => "table",
}
}
pub fn parse(s: &str) -> Self {
match s {
"component" => NodeKind::Component,
"grpc" => NodeKind::Grpc,
"table" => NodeKind::Table,
_ => NodeKind::CoreFn,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ArchNode {
pub id: String,
pub label: String,
pub kind: NodeKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ArchEdgeKind {
Calls,
Reads,
Writes,
}
impl ArchEdgeKind {
pub fn as_str(self) -> &'static str {
match self {
ArchEdgeKind::Calls => "calls",
ArchEdgeKind::Reads => "reads",
ArchEdgeKind::Writes => "writes",
}
}
pub fn parse(s: &str) -> Self {
match s {
"reads" => ArchEdgeKind::Reads,
"writes" => ArchEdgeKind::Writes,
_ => ArchEdgeKind::Calls,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ArchEdge {
pub from: String,
pub to: String,
pub kind: ArchEdgeKind,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchGraph {
pub nodes: Vec<ArchNode>,
pub edges: Vec<ArchEdge>,
}
pub struct Classifier<'a> {
pub facet_impls: &'a BTreeSet<String>,
pub grpc_handlers: &'a BTreeMap<String, String>,
}
impl<'a> Classifier<'a> {
pub fn classify(&self, fq: &str) -> ArchNode {
if let Some(label) = self.grpc_handlers.get(fq) {
return ArchNode {
id: format!("grpc:{label}"),
label: label.clone(),
kind: NodeKind::Grpc,
};
}
let segs: Vec<&str> = fq.split("::").collect();
if segs.len() >= 2 {
let owner_ty = segs[segs.len() - 2];
if self.facet_impls.contains(owner_ty) {
return ArchNode {
id: format!("component:{owner_ty}"),
label: owner_ty.to_string(),
kind: NodeKind::Component,
};
}
}
let bucket = core_bucket(fq);
ArchNode {
id: format!("corefn:{bucket}"),
label: bucket,
kind: NodeKind::CoreFn,
}
}
}
fn core_bucket(fq: &str) -> String {
let segs: Vec<&str> = fq.split("::").collect();
match segs.len() {
0 => "unknown".to_string(),
1 => segs[0].to_string(),
2 => segs[0].to_string(), _ => format!("{}::{}", segs[0], segs[1]),
}
}
pub fn facet_impls_from_symbols(symbols: &[SymbolRow]) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for s in symbols {
if s.item_kind == "impl" {
if let Some(t) = parse_facet_impl(&s.item_name) {
out.insert(t);
}
}
}
out
}
pub fn grpc_handlers_from_symbols(symbols: &[SymbolRow]) -> BTreeMap<String, String> {
let mut svc_types: BTreeSet<String> = BTreeSet::new();
for s in symbols {
if s.item_kind == "impl" {
if let Some(ty) = parse_grpc_impl(&s.item_name) {
svc_types.insert(ty);
}
}
}
let mut out = BTreeMap::new();
for s in symbols {
if s.item_kind != "fn" {
continue;
}
let owner_ty = s.module_path.rsplit("::").next().unwrap_or("");
if !svc_types.contains(owner_ty) {
continue;
}
let service = owner_ty.strip_suffix("Svc").unwrap_or(owner_ty);
let verb = to_camel(&s.item_name);
let label = format!("{service}.{verb}");
let fq = format!("{}::{}", s.module_path, s.item_name);
out.insert(fq, label);
}
out
}
fn parse_grpc_impl(label: &str) -> Option<String> {
let rest = label.strip_prefix("impl ")?;
let (trait_part, ty_part) = rest.split_once(" for ")?;
let trait_last = trait_part.rsplit("::").next().unwrap_or(trait_part).trim();
let ty = ty_part.trim();
let bare = ty.split(['<', ' ']).next().unwrap_or(ty);
let last = bare.rsplit("::").next().unwrap_or(bare).trim();
if trait_last.ends_with("Trait") && last.ends_with("Svc") && !last.is_empty() {
Some(last.to_string())
} else {
None
}
}
fn to_camel(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut upper = true;
for c in s.chars() {
if c == '_' {
upper = true;
} else if upper {
out.extend(c.to_uppercase());
upper = false;
} else {
out.push(c);
}
}
out
}
pub fn generate(
symbols: &[SymbolRow],
calls: &[CallEdgeRow],
access: &[AccessEdge],
) -> ArchGraph {
let facets = facet_impls_from_symbols(symbols);
let handlers = grpc_handlers_from_symbols(symbols);
let cls = Classifier { facet_impls: &facets, grpc_handlers: &handlers };
build_graph(symbols, calls, access, &cls)
}
fn parse_facet_impl(label: &str) -> Option<String> {
let rest = label.strip_prefix("impl ")?;
let (trait_part, ty_part) = rest.split_once(" for ")?;
let trait_last = trait_part.rsplit("::").next().unwrap_or(trait_part).trim();
if trait_last != "Facet" {
return None;
}
let ty = ty_part.trim();
let bare = ty.split(['<', ' ']).next().unwrap_or(ty);
let last = bare.rsplit("::").next().unwrap_or(bare).trim();
if last.is_empty() {
None
} else {
Some(last.to_string())
}
}
pub fn build_graph(
symbols: &[SymbolRow],
calls: &[CallEdgeRow],
access: &[AccessEdge],
cls: &Classifier<'_>,
) -> ArchGraph {
let mut nodes: BTreeMap<String, ArchNode> = BTreeMap::new();
let mut edges: BTreeSet<ArchEdge> = BTreeSet::new();
let add_node = |nodes: &mut BTreeMap<String, ArchNode>, n: ArchNode| {
nodes.entry(n.id.clone()).or_insert(n);
};
for s in symbols {
if s.item_kind != "fn" {
continue;
}
let fq = format!("{}::{}", s.module_path, s.item_name);
add_node(&mut nodes, cls.classify(&fq));
}
for c in calls {
let from = cls.classify(&c.caller_path);
if !c.callee_ident.contains("::") {
add_node(&mut nodes, from);
continue;
}
let to = cls.classify(&c.callee_ident);
let (from_id, to_id) = (from.id.clone(), to.id.clone());
add_node(&mut nodes, from);
add_node(&mut nodes, to);
if from_id != to_id {
edges.insert(ArchEdge { from: from_id, to: to_id, kind: ArchEdgeKind::Calls });
}
}
for a in access {
if a.table == crate::warehouse::access_scan::DYNAMIC_TABLE {
continue;
}
let from = cls.classify(&a.caller_fn);
let from_id = from.id.clone();
let table_id = format!("table:{}", a.table);
add_node(&mut nodes, from);
add_node(
&mut nodes,
ArchNode {
id: table_id.clone(),
label: a.table.clone(),
kind: NodeKind::Table,
},
);
let kind = match a.access {
Access::Read => ArchEdgeKind::Reads,
Access::Write => ArchEdgeKind::Writes,
};
if from_id != table_id {
edges.insert(ArchEdge { from: from_id, to: table_id, kind });
}
}
let mut nodes: Vec<ArchNode> = nodes.into_values().collect();
nodes.sort();
let edges: Vec<ArchEdge> = edges.into_iter().collect();
ArchGraph { nodes, edges }
}
pub fn trace_from(graph: &ArchGraph, entrypoint: &str) -> ArchGraph {
use std::collections::{HashMap, VecDeque};
let needle = entrypoint.to_lowercase();
let seeds: BTreeSet<&str> = graph
.nodes
.iter()
.filter(|n| {
n.label.to_lowercase().contains(&needle) || n.id.to_lowercase().contains(&needle)
})
.map(|n| n.id.as_str())
.collect();
if seeds.is_empty() {
return ArchGraph::default();
}
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in &graph.edges {
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
}
let mut reached: BTreeSet<&str> = BTreeSet::new();
let mut queue: VecDeque<&str> = VecDeque::new();
for &s in &seeds {
if reached.insert(s) {
queue.push_back(s);
}
}
while let Some(cur) = queue.pop_front() {
if let Some(outs) = adj.get(cur) {
for &nxt in outs {
if reached.insert(nxt) {
queue.push_back(nxt);
}
}
}
}
let by_id: BTreeMap<&str, &ArchNode> =
graph.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let mut nodes: Vec<ArchNode> = reached
.iter()
.filter_map(|id| by_id.get(id).map(|n| (*n).clone()))
.collect();
nodes.sort();
let edges: Vec<ArchEdge> = graph
.edges
.iter()
.filter(|e| reached.contains(e.from.as_str()) && reached.contains(e.to.as_str()))
.cloned()
.collect();
ArchGraph { nodes, edges }
}
impl ArchGraph {
pub fn to_svg(&self) -> String {
render_pcb_svg(self)
}
pub fn to_text(&self) -> String {
let mut s = String::new();
s.push_str("nodes:\n");
for n in &self.nodes {
s.push_str(&format!(" [{}] {}\n", n.kind.as_str(), n.label));
}
if self.edges.is_empty() {
s.push_str("edges: (none)\n");
} else {
s.push_str("edges:\n");
for e in &self.edges {
s.push_str(&format!(" {} → {} ({})\n", e.from, e.to, e.kind.as_str()));
}
}
s
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn layers_of(graph: &ArchGraph) -> Vec<Vec<usize>> {
use std::collections::HashMap;
let n = graph.nodes.len();
let idx: HashMap<&str, usize> =
graph.nodes.iter().enumerate().map(|(i, nd)| (nd.id.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 &graph.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: BTreeSet<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 mut max_level = *layer_of.iter().max().unwrap_or(&0);
let has_table = graph.nodes.iter().any(|n| n.kind == NodeKind::Table);
if has_table {
max_level = max_level.max(1);
for (i, nd) in graph.nodes.iter().enumerate() {
if nd.kind == NodeKind::Table {
layer_of[i] = max_level;
}
}
}
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| graph.nodes[a].label.cmp(&graph.nodes[b].label));
for i in order {
layers[layer_of[i]].push(i);
}
layers
}
fn kind_style(kind: NodeKind) -> (&'static str, &'static str) {
match kind {
NodeKind::Component => ("#14384f", "#3aa0d0"), NodeKind::Grpc => ("#3a2a14", "#d0962a"), NodeKind::CoreFn => ("#173d57", "#5a8fb0"), NodeKind::Table => ("#0c2438", "#2db6e0"), }
}
fn render_pcb_svg(graph: &ArchGraph) -> String {
use std::collections::HashMap;
if graph.nodes.is_empty() {
return String::from(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 80\" \
font-family=\"'DejaVu Sans', sans-serif\">\
<rect x=\"0\" y=\"0\" width=\"320\" height=\"80\" fill=\"#06111d\"/>\
<text x=\"16\" y=\"44\" fill=\"#5a8fb0\" font-size=\"14\">(empty architecture graph)</text>\
</svg>\n",
);
}
let layers = layers_of(graph);
let col_w = 240.0f64;
let row_h = 56.0f64;
let box_w = 188.0f64;
let box_h = 34.0f64;
let margin = 28.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 id_to_idx: HashMap<&str, usize> =
graph.nodes.iter().enumerate().map(|(i, nd)| (nd.id.as_str(), i)).collect();
let mut s = String::new();
s.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width:.0} {height:.0}\" \
font-family=\"'DejaVu Sans', 'Segoe UI', sans-serif\" font-size=\"12\">\n"
));
s.push_str(
"<defs>\
<linearGradient id=\"arch-board\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\
<stop offset=\"0\" stop-color=\"#0b1c2e\"/><stop offset=\"1\" stop-color=\"#06111d\"/>\
</linearGradient>\
<marker id=\"arch-arrow\" markerWidth=\"9\" markerHeight=\"9\" refX=\"7\" refY=\"3\" \
orient=\"auto\"><path d=\"M0,0 L7,3 L0,6 Z\" fill=\"#3aa0d0\"/></marker>\
<marker id=\"arch-arrow-rw\" markerWidth=\"9\" markerHeight=\"9\" refX=\"7\" refY=\"3\" \
orient=\"auto\"><path d=\"M0,0 L7,3 L0,6 Z\" fill=\"#d0962a\"/></marker>\
</defs>\n",
);
s.push_str(&format!(
"<rect x=\"0\" y=\"0\" width=\"{width:.0}\" height=\"{height:.0}\" fill=\"url(#arch-board)\"/>\n"
));
for e in &graph.edges {
let (Some(&fi), Some(&ti)) =
(id_to_idx.get(e.from.as_str()), id_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 mid = (x1 + x2) / 2.0;
let (color, marker, dash) = match e.kind {
ArchEdgeKind::Calls => ("#3aa0d0", "url(#arch-arrow)", ""),
ArchEdgeKind::Reads => ("#d0962a", "url(#arch-arrow-rw)", " stroke-dasharray=\"6 4\""),
ArchEdgeKind::Writes => ("#e0b050", "url(#arch-arrow-rw)", ""),
};
s.push_str(&format!(
"<path d=\"M{x1:.0},{y1:.0} C{mid:.0},{y1:.0} {mid:.0},{y2:.0} {x2:.0},{y2:.0}\" \
fill=\"none\" stroke=\"{color}\" stroke-width=\"1.6\"{dash} \
marker-end=\"{marker}\" opacity=\"0.85\"/>\n"
));
}
for (i, nd) in graph.nodes.iter().enumerate() {
let Some(&(x, y)) = pos.get(&i) else { continue };
let (fill, stroke) = kind_style(nd.kind);
let label = xml_escape(&nd.label);
s.push_str(&format!(
"<rect x=\"{x:.0}\" y=\"{y:.0}\" width=\"{box_w:.0}\" height=\"{box_h:.0}\" rx=\"5\" \
fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"1.4\"/>\n"
));
s.push_str(&format!(
"<circle cx=\"{:.0}\" cy=\"{:.0}\" r=\"2.4\" fill=\"{stroke}\"/>\n",
x + 6.0,
y + box_h / 2.0,
));
s.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\" fill=\"#dbeaf4\">{label}</text>\n",
x + box_w / 2.0 + 4.0,
y + box_h / 2.0 + 4.0,
));
}
s.push_str("</svg>\n");
s
}
#[cfg(test)]
mod tests {
use super::*;
fn sym_fn(module_path: &str, name: &str) -> SymbolRow {
SymbolRow {
crate_name: "nornir".into(),
module_path: module_path.into(),
item_kind: "fn".into(),
item_name: name.into(),
visibility: "pub".into(),
file: "src/x.rs".into(),
line: 1,
doc_lines: 0,
signature: None,
}
}
fn sym_impl(module_path: &str, label: &str) -> SymbolRow {
SymbolRow {
crate_name: "nornir".into(),
module_path: module_path.into(),
item_kind: "impl".into(),
item_name: label.into(),
visibility: "pub".into(),
file: "src/x.rs".into(),
line: 1,
doc_lines: 0,
signature: None,
}
}
fn call(caller: &str, callee: &str) -> CallEdgeRow {
CallEdgeRow {
crate_name: "nornir".into(),
caller_path: caller.into(),
callee_ident: callee.into(),
call_kind: "call".into(),
file: "src/x.rs".into(),
line: 1,
}
}
fn access(caller: &str, table: &str, a: Access) -> AccessEdge {
AccessEdge {
caller_fn: caller.into(),
crate_name: "nornir".into(),
table: table.into(),
access: a,
file: "src/x.rs".into(),
line: 1,
}
}
#[test]
fn parse_facet_impl_only_facet_trait() {
assert_eq!(parse_facet_impl("impl Facet for TestTab").as_deref(), Some("TestTab"));
assert_eq!(
parse_facet_impl("impl crate::facett::Facet for Grid<T>").as_deref(),
Some("Grid")
);
assert_eq!(parse_facet_impl("impl Clone for Foo"), None);
assert_eq!(parse_facet_impl("impl Foo"), None);
}
#[test]
fn core_bucket_collapses_to_crate_module() {
assert_eq!(core_bucket("nornir::viz::live::Loader::hydrate"), "nornir::viz");
assert_eq!(core_bucket("nornir::viz::build_timeline"), "nornir::viz");
assert_eq!(core_bucket("nornir::main"), "nornir");
assert_eq!(core_bucket("nornir"), "nornir");
}
#[test]
fn test_tab_fetch_collapses_to_component_and_table() {
let symbols = vec![
sym_impl("nornir::viz::test_tab", "impl Facet for TestTab"),
sym_fn("nornir::viz::test_tab::TestTab", "fetch_test_results"),
];
let calls = vec![call(
"nornir::viz::test_tab::TestTab::fetch_test_results",
"query_test_results",
)];
let acc = vec![access(
"nornir::viz::test_tab::TestTab::fetch_test_results",
"test_results",
Access::Read,
)];
let facets = facet_impls_from_symbols(&symbols);
assert!(facets.contains("TestTab"));
let cls = Classifier { facet_impls: &facets, grpc_handlers: &BTreeMap::new() };
let g = build_graph(&symbols, &calls, &acc, &cls);
let comp = g
.nodes
.iter()
.find(|n| n.kind == NodeKind::Component && n.label == "TestTab")
.expect("TestTab component node");
let tbl = g
.nodes
.iter()
.find(|n| n.kind == NodeKind::Table && n.label == "test_results")
.expect("test_results table node");
assert!(
g.edges.iter().any(|e| e.from == comp.id
&& e.to == tbl.id
&& e.kind == ArchEdgeKind::Reads),
"expected TestTab -reads-> test_results; got {:?}",
g.edges
);
assert!(
g.nodes.iter().all(|n| n.label != "query_test_results"),
"accessor ident should not be a standalone node: {:?}",
g.nodes
);
}
#[test]
fn grpc_handler_classifies_and_collapses() {
let symbols = vec![sym_fn("nornir::server::Viz", "timeline")];
let mut handlers = BTreeMap::new();
handlers.insert("nornir::server::Viz::timeline".to_string(), "Viz.Timeline".to_string());
let calls = vec![call("nornir::server::Viz::timeline", "build_timeline::foo")];
let acc = vec![access("nornir::server::Viz::timeline", "release_lineage", Access::Write)];
let facets = BTreeSet::new();
let cls = Classifier { facet_impls: &facets, grpc_handlers: &handlers };
let g = build_graph(&symbols, &calls, &acc, &cls);
let grpc = g
.nodes
.iter()
.find(|n| n.kind == NodeKind::Grpc && n.label == "Viz.Timeline")
.expect("Viz.Timeline gRPC node");
let tbl = g
.nodes
.iter()
.find(|n| n.kind == NodeKind::Table && n.label == "release_lineage")
.unwrap();
assert!(g.edges.iter().any(|e| e.from == grpc.id
&& e.to == tbl.id
&& e.kind == ArchEdgeKind::Writes));
}
#[test]
fn self_edges_and_dupes_collapse_away() {
let symbols = vec![sym_fn("nornir::mimir", "a"), sym_fn("nornir::mimir", "b")];
let calls = vec![
call("nornir::mimir::a", "nornir::mimir::b"),
call("nornir::mimir::b", "nornir::mimir::a"),
];
let facets = BTreeSet::new();
let cls = Classifier { facet_impls: &facets, grpc_handlers: &BTreeMap::new() };
let g = build_graph(&symbols, &calls, &[], &cls);
assert!(g.edges.is_empty(), "intra-module self-edge must collapse: {:?}", g.edges);
assert_eq!(g.nodes.len(), 1);
assert_eq!(g.nodes[0].kind, NodeKind::CoreFn);
assert_eq!(g.nodes[0].label, "nornir::mimir");
}
#[test]
fn dynamic_table_access_is_skipped() {
let acc = vec![access("nornir::viz::warehouse_tab::run", "<dynamic>", Access::Read)];
let facets = BTreeSet::new();
let cls = Classifier { facet_impls: &facets, grpc_handlers: &BTreeMap::new() };
let g = build_graph(&[], &[], &acc, &cls);
assert!(g.nodes.iter().all(|n| n.kind != NodeKind::Table), "no <dynamic> table node");
}
#[test]
fn svg_is_circuit_board_with_expected_labels() {
let symbols = vec![
sym_impl("nornir::viz::test_tab", "impl Facet for TestTab"),
sym_fn("nornir::viz::test_tab::TestTab", "fetch_test_results"),
];
let acc = vec![access(
"nornir::viz::test_tab::TestTab::fetch_test_results",
"test_results",
Access::Read,
)];
let facets = facet_impls_from_symbols(&symbols);
let cls = Classifier { facet_impls: &facets, grpc_handlers: &BTreeMap::new() };
let g = build_graph(&symbols, &[], &acc, &cls);
let svg = g.to_svg();
assert!(svg.starts_with("<svg"), "{svg}");
assert!(svg.contains("</svg>"));
assert!(!svg.contains("mermaid"));
assert!(svg.contains("arch-board"), "PCB substrate gradient: {svg}");
assert!(svg.contains("<path "), "edge traces: {svg}");
assert!(svg.contains(">TestTab<"), "TestTab chip label: {svg}");
assert!(svg.contains(">test_results<"), "table chip label: {svg}");
assert!(svg.len() > 400);
}
#[test]
fn grpc_handlers_derived_from_symbols() {
let symbols = vec![
sym_impl("nornir::server", "impl VizSvcTrait for VizSvc"),
sym_fn("nornir::server::VizSvc", "timeline"),
sym_fn("nornir::server::VizSvc", "bakeoff_results"),
sym_impl("nornir::server", "impl Clone for VizSvc"),
];
let handlers = grpc_handlers_from_symbols(&symbols);
assert_eq!(
handlers.get("nornir::server::VizSvc::timeline").map(String::as_str),
Some("Viz.Timeline")
);
assert_eq!(
handlers.get("nornir::server::VizSvc::bakeoff_results").map(String::as_str),
Some("Viz.BakeoffResults")
);
}
#[test]
fn generate_end_to_end_derives_both_maps() {
let symbols = vec![
sym_impl("nornir::viz::test_tab", "impl Facet for TestTab"),
sym_fn("nornir::viz::test_tab::TestTab", "fetch_test_results"),
sym_impl("nornir::server", "impl VizSvcTrait for VizSvc"),
sym_fn("nornir::server::VizSvc", "test_results"),
];
let calls = vec![];
let acc = vec![
access("nornir::viz::test_tab::TestTab::fetch_test_results", "test_results", Access::Read),
access("nornir::server::VizSvc::test_results", "test_results", Access::Read),
];
let g = generate(&symbols, &calls, &acc);
assert!(g.nodes.iter().any(|n| n.kind == NodeKind::Component && n.label == "TestTab"));
assert!(g.nodes.iter().any(|n| n.kind == NodeKind::Grpc && n.label == "Viz.TestResults"));
assert!(g.nodes.iter().any(|n| n.kind == NodeKind::Table && n.label == "test_results"));
let tbl = g.nodes.iter().find(|n| n.kind == NodeKind::Table).unwrap();
let reads: Vec<&str> = g
.edges
.iter()
.filter(|e| e.to == tbl.id && e.kind == ArchEdgeKind::Reads)
.map(|e| e.from.as_str())
.collect();
assert!(reads.contains(&"component:TestTab"), "{reads:?}");
assert!(reads.contains(&"grpc:Viz.TestResults"), "{reads:?}");
}
#[test]
fn trace_from_entrypoint_keeps_only_reachable() {
let g = ArchGraph {
nodes: vec![
ArchNode { id: "component:TestTab".into(), label: "TestTab".into(), kind: NodeKind::Component },
ArchNode { id: "component:MapTab".into(), label: "MapTab".into(), kind: NodeKind::Component },
ArchNode { id: "table:test_results".into(), label: "test_results".into(), kind: NodeKind::Table },
ArchNode { id: "table:map_tiles".into(), label: "map_tiles".into(), kind: NodeKind::Table },
],
edges: vec![
ArchEdge { from: "component:TestTab".into(), to: "table:test_results".into(), kind: ArchEdgeKind::Reads },
ArchEdge { from: "component:MapTab".into(), to: "table:map_tiles".into(), kind: ArchEdgeKind::Reads },
],
};
let sub = trace_from(&g, "TestTab");
let labels: BTreeSet<&str> = sub.nodes.iter().map(|n| n.label.as_str()).collect();
assert!(labels.contains("TestTab"), "{labels:?}");
assert!(labels.contains("test_results"), "{labels:?}");
assert!(!labels.contains("MapTab"), "unreachable pruned: {labels:?}");
assert!(!labels.contains("map_tiles"), "unreachable pruned: {labels:?}");
assert_eq!(sub.edges.len(), 1);
assert_eq!(sub.edges[0].from, "component:TestTab");
assert_eq!(sub.edges[0].to, "table:test_results");
assert_eq!(sub.edges[0].kind, ArchEdgeKind::Reads);
let svg = sub.to_svg();
assert!(svg.contains(">TestTab<") && svg.contains(">test_results<"));
assert!(trace_from(&g, "no_such_surface").nodes.is_empty());
}
#[test]
fn empty_graph_renders_placeholder_svg() {
let svg = ArchGraph::default().to_svg();
assert!(svg.starts_with("<svg"));
assert!(svg.contains("empty architecture graph"));
}
}