nornir 0.3.1

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Knowledge-map tab: in-process scan + bubble chart of repos.
//!
//! On entry the tab spawns one [`crate::knowledge::scan_all`] per
//! configured repo (sync, on the UI thread — small scans are sub-100ms
//! so this is fine). Caches results until "↻ rescan" is clicked.
//!
//! Visual model:
//!   - one bubble per crate, x = symbol-count, y = call-count (both log)
//!   - bubble radius = file count of the crate
//!   - bubble color = git heat (max commits-30d across the crate's files)
//!   - hovering shows breakdown (kinds, top-N functions by call-out)

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Instant;

use eframe::egui::{self, Color32, Pos2, Sense, Stroke, Vec2};

use crate::knowledge::{self, ScanResult};

pub struct KnowledgeState {
    workspace_root: PathBuf,
    repos: Vec<String>,
    scans: Vec<(String, Result<ScanResult, String>)>,
    last_scan: Option<Instant>,
    rescan_requested: 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,
        }
    }

    pub fn draw(&mut self, ui: &mut egui::Ui) {
        ui.horizontal(|ui| {
            ui.heading("🗺 Knowledge map");
            ui.separator();
            if ui.button("↻ rescan").clicked() {
                self.rescan_requested = true;
            }
            if let Some(when) = self.last_scan {
                ui.label(format!("last scanned {}s ago", when.elapsed().as_secs()));
            }
        });
        ui.separator();

        if self.rescan_requested {
            self.scans.clear();
            for repo in &self.repos {
                let repo_root = self.workspace_root.join(repo);
                let scan = knowledge::scan_all(&repo_root, repo)
                    .map_err(|e| format!("{e:#}"));
                self.scans.push((repo.clone(), scan));
            }
            self.last_scan = Some(Instant::now());
            self.rescan_requested = false;
        }

        let bubbles = collect_bubbles(&self.scans);
        if bubbles.is_empty() {
            ui.label("No data yet — click ↻ rescan or check workspace path.");
            return;
        }

        // Header table of per-crate totals.
        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);
    }
}

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

        // Heat: max 30d commits across all files in the repo, applied per-crate
        // (we lack module→crate mapping in git_heat, so use repo-wide max as a
        // simple proxy; refine later by joining file prefixes with crate dirs).
        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,
        );
    }

    // 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),
        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 {
    // cold → warm: deep teal → orange
    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) {}