use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use eframe::egui::{self, Color32, Pos2, Sense, Stroke, Vec2};
use serde::Serialize;
use crate::knowledge::{self, ScanResult};
use crate::viz::trace;
type ScanSlot = Arc<Mutex<Option<Vec<(String, Result<ScanResult, String>)>>>>;
#[derive(Serialize)]
struct ScanIn {
workspace_root: String,
repos: Vec<String>,
}
#[derive(Serialize)]
struct RepoScan {
repo: String,
path: String,
exists: bool,
symbols: usize,
calls: usize,
features: usize,
git_files: usize,
ms: u64,
error: Option<String>,
}
#[derive(Serialize)]
struct ScanOut {
repos_ok: usize,
repos_total: usize,
ms: u64,
}
#[derive(Serialize)]
struct RenderOut<'a> {
crates: usize,
bubbles: &'a [Bubble],
}
pub struct KnowledgeState {
workspace_root: PathBuf,
repos: Vec<String>,
scans: Vec<(String, Result<ScanResult, String>)>,
last_scan: Option<Instant>,
rescan_requested: bool,
pending: Option<(ScanSlot, Instant)>,
render_dirty: bool,
}
impl KnowledgeState {
pub fn new(workspace_root: PathBuf, repos: Vec<String>) -> Self {
Self {
workspace_root,
repos,
scans: Vec::new(),
last_scan: None,
rescan_requested: true,
pending: None,
render_dirty: false,
}
}
pub fn set_workspace(&mut self, workspace_root: PathBuf, repos: Vec<String>) {
self.workspace_root = workspace_root;
self.repos = repos;
self.scans.clear();
self.last_scan = None;
self.pending = None;
self.rescan_requested = true;
self.render_dirty = false;
}
fn start_scan(&mut self) {
let slot: ScanSlot = Arc::new(Mutex::new(None));
let sink = slot.clone();
let workspace_root = self.workspace_root.clone();
let repos = self.repos.clone();
trace::emit_in(
"knowledge.scan",
&ScanIn {
workspace_root: workspace_root.display().to_string(),
repos: repos.clone(),
},
);
std::thread::spawn(move || {
use rayon::prelude::*;
let t0 = Instant::now();
let scans: Vec<(String, Result<ScanResult, String>)> = repos
.par_iter()
.map(|repo| {
let repo_root = workspace_root.join(repo);
let exists = repo_root.exists();
let r0 = Instant::now();
let scan = knowledge::scan_all(&repo_root, repo).map_err(|e| format!("{e:#}"));
let result = match &scan {
Ok(s) => RepoScan {
repo: repo.clone(),
path: repo_root.display().to_string(),
exists,
symbols: s.symbols.symbols.len(),
calls: s.symbols.calls.len(),
features: s.symbols.features.len(),
git_files: s.git.files.len(),
ms: r0.elapsed().as_millis() as u64,
error: None,
},
Err(e) => RepoScan {
repo: repo.clone(),
path: repo_root.display().to_string(),
exists,
symbols: 0,
calls: 0,
features: 0,
git_files: 0,
ms: r0.elapsed().as_millis() as u64,
error: Some(e.clone()),
},
};
trace::emit_end("knowledge.scan.repo", &result);
(repo.clone(), scan)
})
.collect();
let ok = scans.iter().filter(|(_, s)| s.is_ok()).count();
trace::emit_out(
"knowledge.scan",
&ScanOut {
repos_ok: ok,
repos_total: scans.len(),
ms: t0.elapsed().as_millis() as u64,
},
);
*sink.lock().unwrap_or_else(|p| p.into_inner()) = Some(scans);
});
self.pending = Some((slot, Instant::now()));
}
#[doc(hidden)]
pub fn scan_blocking_for_test(&mut self) {
use rayon::prelude::*;
let workspace_root = &self.workspace_root;
self.scans = self
.repos
.par_iter()
.map(|repo| {
let repo_root = workspace_root.join(repo);
let scan = knowledge::scan_all(&repo_root, repo).map_err(|e| format!("{e:#}"));
(repo.clone(), scan)
})
.collect();
self.last_scan = Some(Instant::now());
self.rescan_requested = false;
self.pending = None;
self.render_dirty = true;
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
if let Some((slot, _started)) = &self.pending {
let done = slot.lock().unwrap_or_else(|p| p.into_inner()).take();
if let Some(scans) = done {
self.scans = scans;
self.last_scan = Some(Instant::now());
self.pending = None;
self.render_dirty = true;
}
}
if self.rescan_requested && self.pending.is_none() {
self.start_scan();
self.rescan_requested = false;
}
let scanning = self.pending.is_some();
ui.horizontal(|ui| {
ui.heading("🗺 Knowledge map");
ui.separator();
if ui.add_enabled(!scanning, egui::Button::new("↻ rescan")).clicked() {
self.rescan_requested = true;
}
if let Some((_, started)) = &self.pending {
ui.label(format!("⏳ scanning {} repo(s) … {}s", self.repos.len(), started.elapsed().as_secs()));
ui.ctx().request_repaint();
} else if let Some(when) = self.last_scan {
ui.label(format!("last scanned {}s ago", when.elapsed().as_secs()));
}
});
ui.separator();
let bubbles = collect_bubbles(&self.scans);
if self.render_dirty {
trace::emit_end(
"knowledge.render",
&RenderOut { crates: bubbles.len(), bubbles: &bubbles },
);
self.render_dirty = false;
}
if bubbles.is_empty() {
if scanning {
ui.label("⏳ scanning repos (symbol parse + git heat) — the UI stays responsive …");
} else {
ui.label("No data yet — click ↻ rescan or check workspace path.");
}
return;
}
egui::CollapsingHeader::new(format!("📊 {} crates scanned", bubbles.len()))
.default_open(true)
.show(ui, |ui| {
egui::Grid::new("kn_summary").striped(true).num_columns(7).show(ui, |ui| {
ui.label("repo"); ui.label("crate");
ui.label("symbols"); ui.label("calls"); ui.label("files");
ui.label("gates"); ui.label("git heat (30d)");
ui.end_row();
for b in &bubbles {
ui.label(&b.repo); ui.label(&b.krate);
ui.label(format!("{}", b.symbols));
ui.label(format!("{}", b.calls));
ui.label(format!("{}", b.files));
ui.label(format!("{}", b.gates));
ui.label(format!("{}", b.heat_30d));
ui.end_row();
}
});
});
ui.separator();
ui.label("Bubble chart — x: symbol count, y: call count, size: files, color: 30d commits");
draw_bubble_chart(ui, &bubbles);
}
pub fn state_json(&self) -> serde_json::Value {
let bubbles = collect_bubbles(&self.scans);
let repos: Vec<serde_json::Value> = self
.scans
.iter()
.map(|(repo, scan)| match scan {
Ok(s) => serde_json::json!({
"repo": repo,
"ok": true,
"symbols": s.symbols.symbols.len(),
"calls": s.symbols.calls.len(),
"features": s.symbols.features.len(),
"git_files": s.git.files.len(),
}),
Err(e) => serde_json::json!({ "repo": repo, "ok": false, "error": e }),
})
.collect();
serde_json::json!({
"workspace_root": self.workspace_root.display().to_string(),
"configured_repos": self.repos,
"scanning": self.pending.is_some(),
"scanned_repos": self.scans.len(),
"crates": bubbles.len(),
"rows": bubbles,
"repo_totals": repos,
})
}
}
#[derive(Serialize)]
struct Bubble {
repo: String,
krate: String,
symbols: usize,
calls: usize,
files: usize,
gates: usize,
heat_30d: i64,
}
fn collect_bubbles(scans: &[(String, Result<ScanResult, String>)]) -> Vec<Bubble> {
let mut out = Vec::new();
for (repo, scan) in scans {
let Ok(scan) = scan else { continue };
let mut per_crate_syms: BTreeMap<&str, usize> = BTreeMap::new();
let mut per_crate_calls: BTreeMap<&str, usize> = BTreeMap::new();
let mut per_crate_files: BTreeMap<&str, std::collections::BTreeSet<&str>> = BTreeMap::new();
let mut per_crate_gates: BTreeMap<&str, usize> = BTreeMap::new();
for s in &scan.symbols.symbols {
*per_crate_syms.entry(&s.crate_name).or_default() += 1;
per_crate_files.entry(&s.crate_name).or_default().insert(&s.file);
}
for c in &scan.symbols.calls {
*per_crate_calls.entry(&c.crate_name).or_default() += 1;
}
for f in &scan.symbols.features {
*per_crate_gates.entry(&f.crate_name).or_default() += 1;
}
let max_30d = scan.git.files.iter().map(|f| f.commits_30d).max().unwrap_or(0);
for (krate, sym_n) in &per_crate_syms {
out.push(Bubble {
repo: repo.clone(),
krate: krate.to_string(),
symbols: *sym_n,
calls: per_crate_calls.get(krate).copied().unwrap_or(0),
files: per_crate_files.get(krate).map(|s| s.len()).unwrap_or(0),
gates: per_crate_gates.get(krate).copied().unwrap_or(0),
heat_30d: max_30d,
});
}
}
out.sort_by_key(|b| std::cmp::Reverse(b.symbols));
out
}
fn draw_bubble_chart(ui: &mut egui::Ui, bubbles: &[Bubble]) {
let avail = ui.available_size_before_wrap();
let plot_w = avail.x.max(400.0);
let plot_h = (avail.y - 40.0).max(300.0);
let (rect, _resp) = ui.allocate_exact_size(Vec2::new(plot_w, plot_h), Sense::hover());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 4.0, Color32::from_rgb(20, 20, 28));
let max_sym = bubbles.iter().map(|b| b.symbols).max().unwrap_or(1) as f32;
let max_calls = bubbles.iter().map(|b| b.calls).max().unwrap_or(1) as f32;
let max_files = bubbles.iter().map(|b| b.files).max().unwrap_or(1) as f32;
let max_heat = bubbles.iter().map(|b| b.heat_30d).max().unwrap_or(1) as f32;
let pad = 30.0;
for b in bubbles {
let x_frac = ((b.symbols as f32).max(1.0).ln()) / max_sym.max(1.0).ln().max(0.1);
let y_frac = ((b.calls as f32).max(1.0).ln()) / max_calls.max(1.0).ln().max(0.1);
let cx = rect.min.x + pad + (rect.width() - 2.0 * pad) * x_frac;
let cy = rect.max.y - pad - (rect.height() - 2.0 * pad) * y_frac;
let r = 6.0 + 28.0 * ((b.files as f32) / max_files.max(1.0)).sqrt();
let heat_norm = (b.heat_30d as f32) / max_heat.max(1.0);
let color = heat_color(heat_norm);
painter.circle_filled(Pos2::new(cx, cy), r, color);
painter.circle_stroke(Pos2::new(cx, cy), r, Stroke::new(1.0, Color32::WHITE));
painter.text(
Pos2::new(cx, cy - r - 6.0),
egui::Align2::CENTER_BOTTOM,
&b.krate,
egui::FontId::proportional(10.0),
Color32::LIGHT_GRAY,
);
}
painter.text(
Pos2::new(rect.center().x, rect.max.y - 4.0),
egui::Align2::CENTER_BOTTOM,
"symbols (log) →",
egui::FontId::proportional(11.0),
Color32::GRAY,
);
painter.text(
Pos2::new(rect.min.x + 4.0, rect.center().y),
egui::Align2::LEFT_CENTER,
"↑ calls (log)",
egui::FontId::proportional(11.0),
Color32::GRAY,
);
}
fn heat_color(t: f32) -> Color32 {
let t = t.clamp(0.0, 1.0);
let r = (60.0 + 195.0 * t) as u8;
let g = (160.0 - 80.0 * t) as u8;
let b = (180.0 - 160.0 * t) as u8;
Color32::from_rgb(r, g, b)
}
#[allow(dead_code)]
fn _path_only(_: &Path) {}