use std::collections::HashMap;
use std::path::PathBuf;
use eframe::egui::{self, Color32, FontId, Pos2, Sense, Stroke, Vec2};
use crate::warehouse::iceberg::{IcebergWarehouse, TablePreview};
const SCAN_LIMIT: u32 = 8000;
const MAX_NODES: usize = 160;
enum Src {
Local(PathBuf),
Remote { endpoint: String, token: String },
}
struct Node {
id: String,
label: String,
pos: Pos2,
deg: usize,
}
pub struct CallGraphState {
src: Src,
workspace: String,
loaded: bool,
error: Option<String>,
repos: Vec<String>,
crates_by_repo: HashMap<String, Vec<String>>,
sel_repo: Option<String>,
sel_crate: Option<String>,
call_rows: Option<TablePreview>,
sym_rows: Option<TablePreview>,
selected: Option<usize>,
nodes: Vec<Node>,
edges: Vec<(usize, usize)>,
built_for: Option<(String, String)>,
truncated: bool,
pan: Vec2,
zoom: f32,
}
impl CallGraphState {
pub fn local(root: PathBuf) -> Self {
Self::with(Src::Local(root), String::new())
}
pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
Self::with(Src::Remote { endpoint, token }, workspace)
}
fn with(src: Src, workspace: String) -> Self {
Self {
src,
workspace,
loaded: false,
error: None,
repos: Vec::new(),
crates_by_repo: HashMap::new(),
sel_repo: None,
sel_crate: None,
call_rows: None,
sym_rows: None,
selected: None,
nodes: Vec::new(),
edges: Vec::new(),
built_for: None,
truncated: false,
pan: Vec2::ZERO,
zoom: 1.0,
}
}
fn scan(&self, table: &str) -> Result<TablePreview, String> {
match &self.src {
Src::Local(root) => IcebergWarehouse::open(root)
.and_then(|wh| wh.scan_preview(table, SCAN_LIMIT as usize))
.map_err(|e| format!("{e:#}")),
Src::Remote { endpoint, token } => {
super::remote::scan_table(endpoint, token, table, SCAN_LIMIT, &self.workspace)
.map_err(|e| format!("{e:#}"))
}
}
}
pub(crate) fn set_workspace(&mut self, workspace: String) {
self.workspace = workspace;
self.loaded = false;
self.error = None;
self.repos.clear();
self.crates_by_repo.clear();
self.sel_repo = None;
self.sel_crate = None;
self.call_rows = None;
self.sym_rows = None;
self.selected = None;
self.nodes.clear();
self.edges.clear();
self.built_for = None;
}
fn ensure_loaded(&mut self) {
if self.loaded {
return;
}
self.loaded = true;
let syms = match self.scan("symbol_facts") {
Ok(p) => p,
Err(e) => {
self.error = Some(format!("scan symbol_facts: {e}"));
return;
}
};
let (Some(ri), Some(ci)) = (col(&syms, "repo"), col(&syms, "crate_name")) else {
self.error = Some("symbol_facts missing repo/crate_name columns".into());
return;
};
let mut repos: Vec<String> = Vec::new();
for row in &syms.rows {
let (repo, krate) = (cell(row, ri), cell(row, ci));
if repo.is_empty() {
continue;
}
if !repos.contains(&repo) {
repos.push(repo.clone());
}
let cs = self.crates_by_repo.entry(repo).or_default();
if !krate.is_empty() && !cs.contains(&krate) {
cs.push(krate);
}
}
repos.sort();
for cs in self.crates_by_repo.values_mut() {
cs.sort();
}
self.repos = repos;
if self.sel_repo.is_none() {
self.sel_repo = self.repos.first().cloned();
}
if let Some(r) = &self.sel_repo {
self.sel_crate = self.crates_by_repo.get(r).and_then(|c| c.first().cloned());
}
self.sym_rows = Some(syms);
match self.scan("call_edges") {
Ok(p) => self.call_rows = Some(p),
Err(e) => self.error = Some(format!("scan call_edges: {e}")),
}
}
fn build_graph(&mut self) {
let (Some(repo), Some(krate)) = (self.sel_repo.clone(), self.sel_crate.clone()) else {
self.nodes.clear();
self.edges.clear();
return;
};
if self.built_for.as_ref() == Some(&(repo.clone(), krate.clone())) {
return;
}
self.built_for = Some((repo.clone(), krate.clone()));
self.nodes.clear();
self.edges.clear();
self.selected = None;
self.truncated = false;
let Some(rows) = &self.call_rows else { return };
let (Some(ri), Some(ci), Some(fi), Some(ti)) = (
col(rows, "repo"),
col(rows, "crate_name"),
col(rows, "caller_path"),
col(rows, "callee_ident"),
) else {
self.error = Some("call_edges missing expected columns".into());
return;
};
let mut deg: HashMap<String, usize> = HashMap::new();
let mut raw: Vec<(String, String)> = Vec::new();
for row in &rows.rows {
if cell(row, ri) != repo || cell(row, ci) != krate {
continue;
}
let (from, to) = (short(&cell(row, fi)), short(&cell(row, ti)));
if from.is_empty() || to.is_empty() {
continue;
}
*deg.entry(from.clone()).or_default() += 1;
*deg.entry(to.clone()).or_default() += 1;
raw.push((from, to));
}
let mut keep: Vec<String> = deg.keys().cloned().collect();
keep.sort_by(|a, b| deg[b].cmp(°[a]).then(a.cmp(b)));
if keep.len() > MAX_NODES {
keep.truncate(MAX_NODES);
self.truncated = true;
}
let mut idx: HashMap<String, usize> = HashMap::new();
let n = keep.len().max(1) as f32;
for (i, id) in keep.iter().enumerate() {
let ang = std::f32::consts::TAU * (i as f32) / n;
idx.insert(id.clone(), self.nodes.len());
self.nodes.push(Node {
id: id.clone(),
label: id.clone(),
pos: Pos2::new(220.0 * ang.cos(), 220.0 * ang.sin()),
deg: deg[id],
});
}
for (from, to) in raw {
if let (Some(&a), Some(&b)) = (idx.get(&from), idx.get(&to)) {
if a != b {
self.edges.push((a, b));
}
}
}
self.layout();
self.pan = Vec2::ZERO;
self.zoom = 1.0;
}
fn layout(&mut self) {
let n = self.nodes.len();
if n < 2 {
return;
}
let area = 640.0 * 480.0;
let k = (area / n as f32).sqrt();
let mut disp = vec![Vec2::ZERO; n];
for _ in 0..120 {
for d in disp.iter_mut() {
*d = Vec2::ZERO;
}
for i in 0..n {
for j in (i + 1)..n {
let mut delta = self.nodes[i].pos - self.nodes[j].pos;
let mut dist = delta.length();
if dist < 0.01 {
delta = Vec2::new(0.1 * (i as f32 + 1.0), 0.1);
dist = delta.length();
}
let force = (k * k) / dist;
let push = delta / dist * force;
disp[i] += push;
disp[j] -= push;
}
}
for &(a, b) in &self.edges {
let delta = self.nodes[a].pos - self.nodes[b].pos;
let dist = delta.length().max(0.01);
let force = (dist * dist) / k;
let pull = delta / dist * force;
disp[a] -= pull;
disp[b] += pull;
}
for i in 0..n {
let d = disp[i];
let len = d.length().max(0.01);
self.nodes[i].pos += d / len * len.min(16.0);
}
}
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
self.ensure_loaded();
egui::TopBottomPanel::top("cg_controls").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label("repo:");
let cur_repo = self.sel_repo.clone().unwrap_or_default();
egui::ComboBox::from_id_source("cg_repo")
.selected_text(if cur_repo.is_empty() { "—".into() } else { cur_repo.clone() })
.show_ui(ui, |ui| {
for r in self.repos.clone() {
if ui.selectable_label(self.sel_repo.as_deref() == Some(&r), &r).clicked() {
self.sel_repo = Some(r.clone());
self.sel_crate =
self.crates_by_repo.get(&r).and_then(|c| c.first().cloned());
self.built_for = None;
}
}
});
ui.separator();
ui.label("crate:");
let crates = self
.sel_repo
.as_ref()
.and_then(|r| self.crates_by_repo.get(r))
.cloned()
.unwrap_or_default();
let cur_crate = self.sel_crate.clone().unwrap_or_default();
egui::ComboBox::from_id_source("cg_crate")
.selected_text(if cur_crate.is_empty() { "—".into() } else { cur_crate.clone() })
.show_ui(ui, |ui| {
for c in crates {
if ui.selectable_label(self.sel_crate.as_deref() == Some(&c), &c).clicked() {
self.sel_crate = Some(c);
self.built_for = None;
}
}
});
ui.separator();
if ui.button("↻ reload").clicked() {
self.loaded = false;
self.call_rows = None;
self.crates_by_repo.clear();
self.repos.clear();
self.built_for = None;
self.error = None;
}
if ui.button("⊙ fit").clicked() {
self.pan = Vec2::ZERO;
self.zoom = 1.0;
}
});
});
self.build_graph();
if let Some(sel) = self.selected {
if sel >= self.nodes.len() {
self.selected = None;
} else {
let mut jump: Option<usize> = None;
let mut close = false;
egui::SidePanel::right("cg_details").default_width(300.0).show_inside(ui, |ui| {
let node = &self.nodes[sel];
ui.horizontal(|ui| {
ui.heading(&node.label);
if ui.button("✖").on_hover_text("close").clicked() {
close = true;
}
});
ui.label(format!("call degree: {}", node.deg));
ui.separator();
if let (Some(syms), Some((repo, krate))) = (&self.sym_rows, &self.built_for) {
if let (Some(ri), Some(ci), Some(ni)) =
(col(syms, "repo"), col(syms, "crate_name"), col(syms, "item_name"))
{
let (fi, li, ki, si, vi) = (
col(syms, "file"),
col(syms, "line"),
col(syms, "item_kind"),
col(syms, "signature"),
col(syms, "visibility"),
);
for row in syms
.rows
.iter()
.filter(|r| {
cell(r, ri) == *repo
&& cell(r, ci) == *krate
&& cell(r, ni) == node.label
})
.take(3)
{
if let (Some(k), Some(v)) = (ki, vi) {
ui.label(format!("{} {}", cell(row, v), cell(row, k)));
}
if let (Some(f), Some(l)) = (fi, li) {
ui.monospace(format!("{}:{}", cell(row, f), cell(row, l)));
}
if let Some(s) = si {
let sig = cell(row, s);
if !sig.is_empty() {
ui.add(egui::Label::new(
egui::RichText::new(sig).monospace().size(11.0),
).wrap());
}
}
ui.add_space(4.0);
}
}
}
let callers: Vec<usize> =
self.edges.iter().filter(|(_, b)| *b == sel).map(|(a, _)| *a).collect();
let callees: Vec<usize> =
self.edges.iter().filter(|(a, _)| *a == sel).map(|(_, b)| *b).collect();
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.separator();
ui.strong(format!("called by ({})", callers.len()));
for a in callers {
if ui.link(&self.nodes[a].label).clicked() {
jump = Some(a);
}
}
ui.separator();
ui.strong(format!("calls ({})", callees.len()));
for b in callees {
if ui.link(&self.nodes[b].label).clicked() {
jump = Some(b);
}
}
});
});
if close {
self.selected = None;
} else if let Some(j) = jump {
self.selected = Some(j);
self.pan = -(self.nodes[j].pos.to_vec2()) * self.zoom;
}
}
}
egui::CentralPanel::default().show_inside(ui, |ui| {
if let Some(err) = &self.error {
ui.colored_label(Color32::RED, err);
return;
}
if self.nodes.is_empty() {
ui.label(
"no call edges for this crate (pick another repo/crate; \
monitoring fills call_edges as it republishes)",
);
return;
}
let (resp, painter) =
ui.allocate_painter(ui.available_size(), Sense::click_and_drag());
if resp.dragged() {
self.pan += resp.drag_delta();
}
if resp.hovered() {
let scroll = ui.input(|i| i.raw_scroll_delta.y);
if scroll != 0.0 {
self.zoom = (self.zoom * (1.0 + scroll * 0.001)).clamp(0.2, 4.0);
}
}
let origin = resp.rect.center() + self.pan;
let tf = |p: Pos2| origin + p.to_vec2() * self.zoom;
let radius = |deg: usize| (3.0 + (deg as f32).sqrt() * 1.6) * self.zoom.clamp(0.6, 2.0);
if resp.clicked() {
if let Some(click) = resp.interact_pointer_pos() {
self.selected = self
.nodes
.iter()
.enumerate()
.map(|(i, n)| (i, (tf(n.pos) - click).length(), radius(n.deg)))
.filter(|(_, d, r)| *d <= r.max(6.0) + 4.0)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _, _)| i);
}
}
for &(a, b) in &self.edges {
let (pa, pb) = (tf(self.nodes[a].pos), tf(self.nodes[b].pos));
let stroke = match self.selected {
Some(s) if a == s => Stroke::new(1.6, Color32::from_rgb(240, 160, 60)),
Some(s) if b == s => Stroke::new(1.6, Color32::from_rgb(80, 190, 220)),
_ => Stroke::new(1.0, Color32::from_gray(90)),
};
painter.line_segment([pa, pb], stroke);
let dir = (pb - pa).normalized();
let head = pb - dir * (6.0 + 4.0 * self.zoom);
let perp = Vec2::new(-dir.y, dir.x) * 3.0;
painter.line_segment([pb, head + perp], Stroke::new(1.0, Color32::from_gray(120)));
painter.line_segment([pb, head - perp], Stroke::new(1.0, Color32::from_gray(120)));
}
for (i, node) in self.nodes.iter().enumerate() {
let p = tf(node.pos);
let r = radius(node.deg);
painter.circle_filled(p, r, heat(node.deg));
if self.selected == Some(i) {
painter.circle_stroke(p, r + 3.0, Stroke::new(2.0, Color32::WHITE));
}
if self.zoom > 0.85 || node.deg >= 4 || self.selected == Some(i) {
painter.text(
p + Vec2::new(r + 2.0, -r),
egui::Align2::LEFT_BOTTOM,
&node.label,
FontId::proportional(11.0),
Color32::from_gray(210),
);
}
}
let (repo, krate) = self.built_for.clone().unwrap_or_default();
let note = format!(
"{repo} · {krate} — {} fns, {} calls{} · drag to pan, scroll to zoom",
self.nodes.len(),
self.edges.len(),
if self.truncated { format!(" (top {MAX_NODES})") } else { String::new() }
);
let note = format!("{note} · click a node to inspect");
painter.text(
resp.rect.left_top() + Vec2::new(8.0, 8.0),
egui::Align2::LEFT_TOP,
note,
FontId::proportional(12.0),
Color32::from_gray(160),
);
});
}
}
fn col(p: &TablePreview, name: &str) -> Option<usize> {
p.columns.iter().position(|c| c == name)
}
fn cell(row: &[String], i: usize) -> String {
row.get(i).cloned().unwrap_or_default()
}
fn short(path: &str) -> String {
path.rsplit("::").next().unwrap_or(path).to_string()
}
fn heat(deg: usize) -> Color32 {
let t = (deg as f32 / 12.0).clamp(0.0, 1.0);
let r = (90.0 + 150.0 * t) as u8;
let g = (150.0 - 60.0 * t) as u8;
let b = (200.0 - 150.0 * t) as u8;
Color32::from_rgb(r, g, b)
}