Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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}");
    }
}