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::{Deserialize, Serialize};
use crate::knowledge::{self, ScanResult};
use crate::viz::trace;
use super::facett_theme::Theme;
type ScanSlot = Arc<Mutex<Option<Vec<(String, Result<ScanResult, String>)>>>>;
#[derive(Clone)]
struct RemoteSrc {
endpoint: String,
token: String,
workspace: String,
}
type RemoteSlot = Arc<Mutex<Option<Result<KnowledgeSummary, 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>,
remote: Option<RemoteSrc>,
scans: Vec<(String, Result<ScanResult, String>)>,
remote_summary: Option<KnowledgeSummary>,
remote_pending: Option<(RemoteSlot, Instant)>,
remote_error: Option<String>,
last_scan: Option<Instant>,
rescan_requested: bool,
pending: Option<(ScanSlot, Instant)>,
render_dirty: bool,
theme: Theme,
}
impl KnowledgeState {
pub fn new(workspace_root: PathBuf, repos: Vec<String>) -> Self {
Self {
workspace_root,
repos,
remote: None,
scans: Vec::new(),
remote_summary: None,
remote_pending: None,
remote_error: None,
last_scan: None,
rescan_requested: true,
pending: None,
render_dirty: false,
theme: Theme::default(),
}
}
pub fn new_remote(
workspace_root: PathBuf,
repos: Vec<String>,
endpoint: String,
token: String,
workspace: String,
) -> Self {
let mut s = Self::new(workspace_root, repos);
s.remote = Some(RemoteSrc { endpoint, token, workspace });
s
}
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
pub fn set_workspace(&mut self, workspace_root: PathBuf, repos: Vec<String>) {
self.workspace_root = workspace_root;
self.repos = repos;
if let Some(r) = self.remote.as_mut() {
let _ = r;
}
self.scans.clear();
self.remote_summary = None;
self.remote_pending = None;
self.remote_error = None;
self.last_scan = None;
self.pending = None;
self.rescan_requested = true;
self.render_dirty = false;
}
pub fn set_workspace_name(&mut self, workspace: String) {
if let Some(r) = self.remote.as_mut() {
r.workspace = workspace;
}
}
fn start_remote_scan(&mut self, r: RemoteSrc) {
let slot: RemoteSlot = Arc::new(Mutex::new(None));
let sink = slot.clone();
trace::emit_in(
"knowledge.scan",
&ScanIn {
workspace_root: format!("remote {} (ws={})", r.endpoint, r.workspace),
repos: self.repos.clone(),
},
);
std::thread::spawn(move || {
let res = super::remote::knowledge_summary(&r.endpoint, &r.token, &r.workspace)
.map_err(|e| format!("{e:#}"));
*sink.lock().unwrap_or_else(|p| p.into_inner()) = Some(res);
});
self.remote_pending = Some((slot, Instant::now()));
}
fn start_scan(&mut self) {
if let Some(r) = self.remote.clone() {
self.start_remote_scan(r);
return;
}
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;
}
fn drain_remote(&mut self) {
if let Some((slot, _started)) = &self.remote_pending {
let done = slot.lock().unwrap_or_else(|p| p.into_inner()).take();
if let Some(res) = done {
match res {
Ok(summary) => {
self.remote_summary = Some(summary);
self.remote_error = None;
}
Err(e) => self.remote_error = Some(e),
}
self.last_scan = Some(Instant::now());
self.remote_pending = None;
self.render_dirty = true;
}
}
}
fn current_bubbles(&self) -> Vec<Bubble> {
match &self.remote_summary {
Some(s) => s.crates.clone(),
None => collect_bubbles(&self.scans),
}
}
fn is_scanning(&self) -> bool {
self.pending.is_some() || self.remote_pending.is_some()
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
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;
}
}
self.drain_remote();
if self.rescan_requested && !self.is_scanning() {
self.start_scan();
self.rescan_requested = false;
}
let scanning = self.is_scanning();
ui.horizontal(|ui| {
ui.heading("🗺 Knowledge map");
ui.separator();
if ui.add_enabled(!scanning, egui::Button::new("↻ rescan")).clicked() {
self.rescan_requested = true;
}
let started = self
.pending
.as_ref()
.map(|(_, t)| *t)
.or(self.remote_pending.as_ref().map(|(_, t)| *t));
if let Some(started) = started {
let where_ = if self.remote.is_some() { "server" } else { "local" };
ui.label(format!(
"⏳ scanning {} repo(s) [{where_}] … {}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()));
}
});
if let Some(err) = &self.remote_error {
ui.colored_label(Color32::RED, format!("Viz.Knowledge failed: {err}"));
}
ui.separator();
let bubbles = self.current_bubbles();
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, &theme);
}
pub fn state_json(&self) -> serde_json::Value {
let bubbles = self.current_bubbles();
let (repos, scanned_repos): (Vec<serde_json::Value>, usize) =
if let Some(s) = &self.remote_summary {
let v = s
.repos
.iter()
.map(|r| {
if r.ok {
serde_json::json!({
"repo": r.repo, "ok": true,
"symbols": r.symbols, "calls": r.calls,
"features": r.features, "git_files": r.git_files,
})
} else {
serde_json::json!({ "repo": r.repo, "ok": false, "error": r.error })
}
})
.collect();
(v, s.repos.len())
} else {
let v = 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();
(v, self.scans.len())
};
serde_json::json!({
"workspace_root": self.workspace_root.display().to_string(),
"configured_repos": self.repos,
"source": if self.remote.is_some() { "remote" } else { "local" },
"scanning": self.is_scanning(),
"error": self.remote_error,
"scanned_repos": scanned_repos,
"crates": bubbles.len(),
"rows": bubbles,
"repo_totals": repos,
"palette": self.theme.name,
})
}
#[doc(hidden)]
pub fn inject_remote_summary_for_test(&mut self, summary: KnowledgeSummary) {
self.remote_summary = Some(summary);
self.remote_error = None;
self.remote_pending = None;
self.rescan_requested = false;
self.last_scan = Some(Instant::now());
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Bubble {
pub repo: String,
pub krate: String,
pub symbols: usize,
pub calls: usize,
pub files: usize,
pub gates: usize,
pub heat_30d: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RepoScanSummary {
pub repo: String,
pub ok: bool,
#[serde(default)]
pub symbols: usize,
#[serde(default)]
pub calls: usize,
#[serde(default)]
pub features: usize,
#[serde(default)]
pub git_files: usize,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct KnowledgeSummary {
pub repos: Vec<RepoScanSummary>,
pub crates: Vec<Bubble>,
}
pub fn scan_summary_json(scans: &[(String, Result<ScanResult, String>)]) -> serde_json::Value {
let crates = collect_bubbles(scans);
let repos: Vec<RepoScanSummary> = scans
.iter()
.map(|(repo, scan)| match scan {
Ok(s) => RepoScanSummary {
repo: repo.clone(),
ok: true,
symbols: s.symbols.symbols.len(),
calls: s.symbols.calls.len(),
features: s.symbols.features.len(),
git_files: s.git.files.len(),
error: None,
},
Err(e) => RepoScanSummary {
repo: repo.clone(),
ok: false,
error: Some(e.clone()),
..Default::default()
},
})
.collect();
serde_json::to_value(KnowledgeSummary { repos, crates }).unwrap_or_default()
}
impl Default for RepoScanSummary {
fn default() -> Self {
Self {
repo: String::new(),
ok: false,
symbols: 0,
calls: 0,
features: 0,
git_files: 0,
error: None,
}
}
}
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], theme: &Theme) {
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, theme.bg);
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 = theme.heat(heat_norm);
painter.circle_filled(Pos2::new(cx, cy), r, color);
painter.circle_stroke(Pos2::new(cx, cy), r, Stroke::new(1.0, theme.node_stroke));
painter.text(
Pos2::new(cx, cy - r - 6.0),
egui::Align2::CENTER_BOTTOM,
&b.krate,
egui::FontId::proportional(10.0),
theme.text,
);
}
painter.text(
Pos2::new(rect.center().x, rect.max.y - 4.0),
egui::Align2::CENTER_BOTTOM,
"symbols (log) →",
egui::FontId::proportional(11.0),
theme.text_dim,
);
painter.text(
Pos2::new(rect.min.x + 4.0, rect.center().y),
egui::Align2::LEFT_CENTER,
"↑ calls (log)",
egui::FontId::proportional(11.0),
theme.text_dim,
);
}
#[allow(dead_code)]
fn _path_only(_: &Path) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remote_summary_renders_nonempty_rows() {
let mut st = KnowledgeState::new_remote(
PathBuf::from("/remote/holger/git"),
vec!["znippy".into()],
"http://server:7878".into(),
"tok".into(),
"holger".into(),
);
let before = st.state_json();
assert_eq!(before["crates"], 0, "no data before the RPC returns");
assert_eq!(before["source"], "remote");
st.inject_remote_summary_for_test(KnowledgeSummary {
repos: vec![RepoScanSummary {
repo: "znippy".into(),
ok: true,
symbols: 120,
calls: 88,
features: 3,
git_files: 40,
error: None,
}],
crates: vec![Bubble {
repo: "znippy".into(),
krate: "znippy".into(),
symbols: 120,
calls: 88,
files: 40,
gates: 3,
heat_30d: 7,
}],
});
let js = st.state_json();
assert_eq!(js["crates"], 1, "remote map must render the server's crate");
assert_eq!(js["rows"][0]["krate"], "znippy");
assert_eq!(js["rows"][0]["symbols"], 120);
assert_eq!(js["rows"][0]["calls"], 88);
assert_eq!(js["repo_totals"][0]["repo"], "znippy");
assert_eq!(js["repo_totals"][0]["git_files"], 40);
assert_eq!(js["scanned_repos"], 1);
assert_eq!(js["error"], serde_json::Value::Null);
}
#[test]
fn scan_summary_carries_repo_errors() {
let scans: Vec<(String, Result<ScanResult, String>)> =
vec![("ghost".into(), Err("no such repo dir".into()))];
let v = scan_summary_json(&scans);
let summary: KnowledgeSummary = serde_json::from_value(v).unwrap();
assert_eq!(summary.repos.len(), 1);
assert!(!summary.repos[0].ok);
assert_eq!(summary.repos[0].error.as_deref(), Some("no such repo dir"));
assert!(summary.crates.is_empty(), "a failed repo contributes no bubbles");
}
}