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,
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>,
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))
}
pub fn remote(endpoint: String, token: String) -> Self {
Self::with(Src::Remote { endpoint, token })
}
fn with(src: Src) -> Self {
Self {
src,
loaded: false,
error: None,
repos: Vec::new(),
crates_by_repo: HashMap::new(),
sel_repo: None,
sel_crate: None,
call_rows: 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).map_err(|e| format!("{e:#}"))
}
}
}
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());
}
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.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();
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;
for &(a, b) in &self.edges {
let (pa, pb) = (tf(self.nodes[a].pos), tf(self.nodes[b].pos));
painter.line_segment([pa, pb], Stroke::new(1.0, Color32::from_gray(90)));
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 node in &self.nodes {
let p = tf(node.pos);
let r = (3.0 + (node.deg as f32).sqrt() * 1.6) * self.zoom.clamp(0.6, 2.0);
painter.circle_filled(p, r, heat(node.deg));
if self.zoom > 0.85 || node.deg >= 4 {
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() }
);
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)
}