src/viz/knowledge.rs
//! Knowledge-map tab (🗺): one bubble per crate, x = symbol count, y = call count,
//! size = files, color = git heat.
//!
//! **Fed by the code room (Mímir v2).** The map is the reader's `Viz.Knowledge`
//! answer — the crate quotient of the workspace's room: thin mode asks the
//! server over the RPC, fat mode asks the workspace's own code room in process
//! ([`crate::viz::remote::local_surface`]) — the same reader, the same JSON,
//! one decode. A workspace nothing was consumed into is a STATE with its
//! sentence (`not_consumed` …), never an empty map that reads as "no crates";
//! a configured repo the room does not hold is listed as not consumed.
use std::path::PathBuf;
use eframe::egui::{self, Pos2, Sense, Stroke, Vec2};
use serde::{Deserialize, Serialize};
use super::facett_theme::{Theme, ThemeExt};
/// Where the map comes from.
enum Src {
/// The workspace's own code room under this warehouse root.
Local { warehouse_root: PathBuf, workspace: String },
/// A fat pane that was given no warehouse root: it has nothing to ask.
Unrooted,
/// The server's code room, over `Viz.Knowledge`.
Remote { endpoint: String, token: String, workspace: String },
}
pub struct KnowledgeState {
workspace_root: PathBuf,
repos: Vec<String>,
src: Src,
theme: Theme,
loaded: bool,
last_fetch_at: Option<std::time::Instant>,
summary: Option<KnowledgeSummary>,
/// The reader's refusal (or the transport's error), as said.
load_error: Option<String>,
}
impl KnowledgeState {
/// A fat pane. Until [`Self::with_warehouse`] names the warehouse root it
/// has no code room to ask, and says so.
pub fn new(workspace_root: PathBuf, repos: Vec<String>) -> Self {
Self { workspace_root, repos, src: Src::Unrooted, theme: Theme::default(), loaded: false, last_fetch_at: None, summary: None, load_error: None }
}
/// Fat mode: read the code room under `warehouse_root` (`<root>/mimir_v2/`).
pub fn with_warehouse(mut self, warehouse_root: PathBuf, workspace: String) -> Self {
self.src = Src::Local { warehouse_root, workspace };
self
}
/// Thin-mode constructor: the server's code room over `Viz.Knowledge`.
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.src = Src::Remote { endpoint, token, workspace };
s
}
/// Set the facett palette the pane paints with.
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
/// Re-scope to a different workspace (the picker switched).
pub fn set_workspace(&mut self, workspace_root: PathBuf, repos: Vec<String>) {
self.workspace_root = workspace_root;
self.repos = repos;
self.reload();
}
/// Re-point at a new workspace name (thin: the gRPC header; fat: the
/// handle's label).
pub fn set_workspace_name(&mut self, workspace: String) {
match &mut self.src {
Src::Remote { workspace: w, .. } | Src::Local { workspace: w, .. } => {
if *w != workspace {
*w = workspace;
self.loaded = false;
self.last_fetch_at = None;
}
}
Src::Unrooted => {}
}
}
/// Ask the code room NOW (a headless drive; a draw does this lazily).
pub fn load_now(&mut self) {
self.reload();
self.ensure_loaded();
}
/// Ask again on the next draw (after a sync / a consume).
pub fn reload(&mut self) {
self.loaded = false;
self.last_fetch_at = None;
}
fn workspace(&self) -> &str {
match &self.src {
Src::Remote { workspace, .. } | Src::Local { workspace, .. } => workspace,
Src::Unrooted => "",
}
}
/// The reader's answer, from wherever this pane reads.
fn fetch(&self) -> Result<KnowledgeSummary, String> {
let json = match &self.src {
Src::Remote { endpoint, token, workspace } => super::remote::fetch_knowledge(endpoint, token, workspace),
Src::Local { warehouse_root, workspace } => {
super::remote::local_surface(warehouse_root, workspace, "Viz.Knowledge", &serde_json::json!({ "workspace": workspace }))
}
Src::Unrooted => return Err("knowledge map: this pane was given no warehouse root, so it has no code room to ask".to_string()),
};
let json = json.map_err(|e| format!("knowledge map: {e:#}"))?;
serde_json::from_value(json).map_err(|e| format!("knowledge map: the reader's answer is not a knowledge summary: {e}"))
}
/// Load once; while there is no map, ask again on the shared `repo_pane`
/// throttle (eagerly while a job for this workspace runs) so a consume
/// that finishes in the background shows without a click.
fn ensure_loaded(&mut self) {
let populate_active = super::repo_pane::populate_active_for(self.workspace(), "");
if !super::repo_pane::should_refetch(self.loaded, self.last_fetch_at, self.summary.is_none(), populate_active) {
return;
}
self.loaded = true;
self.last_fetch_at = Some(std::time::Instant::now());
match self.fetch() {
Ok(s) => {
self.summary = Some(s);
self.load_error = None;
}
Err(e) => {
self.summary = None;
self.load_error = Some(e);
}
}
}
/// Configured repos the room holds nothing of.
fn not_consumed(&self) -> Vec<String> {
let held: Vec<&str> = self.summary.as_ref().map(|s| s.repos.iter().map(|r| r.repo.as_str()).collect()).unwrap_or_default();
self.repos.iter().filter(|r| !held.contains(&r.as_str())).cloned().collect()
}
/// The whole tab in one pane.
pub fn draw(&mut self, ui: &mut egui::Ui) {
self.draw_summary(ui);
ui.separator();
self.draw_chart(ui);
}
/// The inspector half: per-repo rows, what is not consumed, what the
/// answer is missing.
pub fn draw_summary(&mut self, ui: &mut egui::Ui) {
self.ensure_loaded();
ui.horizontal(|ui| {
ui.heading("🗺 Knowledge map");
ui.separator();
ui.label(format!("{} repo(s) configured", self.repos.len()));
if ui.small_button("↻").on_hover_text("ask the code room again").clicked() {
self.reload();
}
});
ui.separator();
if let Some(e) = &self.load_error {
ui.colored_label(self.theme.text, e);
return;
}
let Some(s) = &self.summary else { return };
for r in &s.repos {
match &r.error {
None => ui.label(format!("{}: {} symbols · {} calls · {} gated · {} files", r.repo, r.symbols, r.calls, r.features, r.git_files)),
Some(e) => ui.colored_label(self.theme.text, format!("{}: {e}", r.repo)),
};
}
let missing = self.not_consumed();
if !missing.is_empty() {
ui.weak(format!("not consumed yet: {}", missing.join(", ")));
}
for m in s.missing() {
ui.weak(format!("missing — {m}"));
}
}
/// The canvas half: the bubbles, or why there are none.
pub fn draw_chart(&mut self, ui: &mut egui::Ui) {
self.ensure_loaded();
match (&self.summary, &self.load_error) {
(Some(s), _) if !s.crates.is_empty() => draw_bubble_chart(ui, &s.crates, &self.theme),
(Some(_), _) => {
ui.weak("knowledge map: the code room holds no local crate of this workspace (see the rows above)");
}
(None, Some(e)) => {
ui.weak(e);
}
(None, None) => {
ui.weak("knowledge map: loading…");
}
}
}
/// The Knowledge tab's slice of `state_json`: the map as data, or the
/// state that says why there is none — never `crates: 0` for "could not
/// read".
pub fn state_json(&self) -> serde_json::Value {
let mut v = serde_json::json!({
"workspace_root": self.workspace_root.display().to_string(),
"configured_repos": self.repos,
"source": match &self.src { Src::Remote { .. } => "remote", Src::Local { .. } | Src::Unrooted => "local" },
"palette": self.theme.name,
});
match (&self.summary, &self.load_error) {
(Some(s), _) => {
v["state"] = "code_room".into();
v["answer_state"] = s.state.clone().into();
v["crates"] = s.crates.len().into();
v["bubbles"] = serde_json::to_value(&s.crates).unwrap_or_default();
v["repos"] = serde_json::to_value(&s.repos).unwrap_or_default();
v["not_consumed"] = self.not_consumed().into();
v["missing"] = s.missing().into();
}
(None, Some(e)) => {
v["state"] = "unavailable".into();
v["note"] = e.clone().into();
}
(None, None) => v["state"] = "not_loaded".into(),
}
v
}
/// Test hook: the pane as if the reader had answered `summary`.
#[cfg(any(test, feature = "testmatrix"))]
pub fn inject_for_test(&mut self, summary: KnowledgeSummary) {
self.summary = Some(summary);
self.load_error = None;
self.loaded = true;
self.last_fetch_at = Some(std::time::Instant::now());
}
}
/// One per-crate bubble — the exact row the Knowledge map renders. Shared
/// between the local scan path and the remote `Viz.Knowledge` decode, so a thin
/// client renders the identical bubbles the embedded scan produces.
#[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,
}
/// One per-repo scan summary (mirrors the embedded `state_json` per-repo block) —
/// the server emits these so the remote tab shows the same `repo_totals` rows.
#[derive(Serialize, Deserialize, Clone, Debug)]
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>,
}
/// A local crate → local crate edge of the map: `from` calls or uses a type
/// of `to` (`edges` item-level edges; `via` = a few of the names used).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct CrateEdge {
pub from: String,
pub to: String,
#[serde(default)]
pub edges: u64,
#[serde(default)]
pub via: Vec<String>,
}
/// The whole knowledge-map summary a `Viz.Knowledge` response carries: the
/// per-repo rollups, the per-crate bubbles, the crate → crate edges, and the
/// reader's state + evidence. The server's reader serialises
/// `code_room::reader::wired::KnowledgeSummary`; this is its decode.
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct KnowledgeSummary {
pub repos: Vec<RepoScanSummary>,
pub crates: Vec<Bubble>,
#[serde(default)]
pub crate_edges: Vec<CrateEdge>,
/// `complete` · `partial` · `empty_fact` · `empty_gap`.
#[serde(default)]
pub state: String,
/// The room's evidence record, as the reader sent it.
#[serde(default)]
pub evidence: serde_json::Value,
}
impl KnowledgeSummary {
/// What the answer is missing, one line each (`input: why`).
pub fn missing(&self) -> Vec<String> {
self.evidence["missing"]
.as_array()
.map(|a| a.iter().map(|m| format!("{}: {}", m["input"].as_str().unwrap_or("?"), m["why"].as_str().unwrap_or(""))).collect())
.unwrap_or_default()
}
}
/// The bubble chart renderer — x: symbol count, y: call count (both log),
/// size: files, color: 30-day git heat. The RENDERING half, kept for lane 8.
pub 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_f32, 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,
);
}
// Axis labels
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,
);
}
#[cfg(test)]
mod tests {
use super::*;
fn wire() -> serde_json::Value {
serde_json::json!({
"repos": [{ "repo": "shop", "ok": true, "symbols": 15, "calls": 9, "features": 2, "git_files": 3, "error": null }],
"crates": [{ "repo": "shop", "krate": "shop", "symbols": 15, "calls": 9, "files": 3, "gates": 2, "heat_30d": 0 }],
"crate_edges": [{ "from": "web", "to": "shop", "edges": 1, "via": ["checkout"] }],
"state": "partial",
"evidence": { "missing": [{ "input": "git_heat_facts", "repo": null, "why": "not read" }], "notes": [] },
})
}
/// The reader's wire JSON decodes into the tab's types, and the pane
/// reports the map as data — with the configured repo the room does not
/// hold named as not consumed.
#[test]
fn the_readers_answer_is_the_map_and_an_unconsumed_repo_is_named() {
let summary: KnowledgeSummary = serde_json::from_value(wire()).unwrap();
assert_eq!(summary.missing(), vec!["git_heat_facts: not read".to_string()]);
let mut st = KnowledgeState::new(PathBuf::from("/ws"), vec!["shop".into(), "ghost".into()]);
st.inject_for_test(summary);
let v = st.state_json();
assert_eq!((v["state"].as_str(), v["answer_state"].as_str(), v["crates"].as_u64()), (Some("code_room"), Some("partial"), Some(1)));
assert_eq!(v["bubbles"][0]["krate"], "shop");
assert_eq!(v["not_consumed"], serde_json::json!(["ghost"]));
assert_eq!(v["missing"], serde_json::json!(["git_heat_facts: not read"]));
}
/// A pane that cannot read says WHY as its state — never a `crates` count
/// that would read as an empty workspace.
#[test]
fn a_pane_that_cannot_read_states_it() {
let mut st = KnowledgeState::new(PathBuf::from("/ws"), vec!["a".into()]);
assert_eq!(st.state_json()["state"], "not_loaded");
st.ensure_loaded();
let v = st.state_json();
assert_eq!(v["state"], "unavailable");
assert!(v["note"].as_str().unwrap().contains("no warehouse root"), "{v}");
assert!(v.get("crates").is_none() && v.get("bubbles").is_none());
}
/// Fat mode end to end: the pane asks the workspace's own code room and
/// gets the map; a workspace nothing was consumed into is the reader's
/// `not_consumed`, said in the pane.
#[cfg(feature = "code-room")]
#[test]
fn a_fat_pane_reads_its_workspaces_code_room() {
use crate::code_room::reader::fixture;
let ws = fixture::shop();
let mut st = KnowledgeState::new(PathBuf::from("/ws"), vec!["shop".into(), "web".into(), "ghost".into()]).with_warehouse(ws.builds.clone(), "ws".into());
st.ensure_loaded();
let v = st.state_json();
assert_eq!(v["state"], "code_room", "{v}");
assert_eq!(v["crates"], 2);
assert_eq!(v["bubbles"][0]["symbols"], 15);
assert_eq!(v["not_consumed"], serde_json::json!(["ghost"]));
let edges = &st.summary.as_ref().unwrap().crate_edges;
assert_eq!(edges.iter().map(|e| (e.from.as_str(), e.to.as_str(), e.edges)).collect::<Vec<_>>(), vec![("web", "shop", 1)]);
assert_eq!(edges[0].via, vec!["checkout".to_string()]);
let fresh = fixture::unconsumed();
let mut st = KnowledgeState::new(PathBuf::from("/ws"), vec!["shop".into()]).with_warehouse(fresh.builds.clone(), "fresh".into());
st.ensure_loaded();
let v = st.state_json();
assert_eq!(v["state"], "unavailable");
assert!(v["note"].as_str().unwrap().contains("[not_consumed]"), "{v}");
}
}