nornir 0.4.11

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 runs one [`crate::knowledge::scan_all`] per configured
//! repo, in parallel via rayon (on the UI thread, so wall-time is the slowest
//! single scan rather than the sum). 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 serde::Serialize;

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

/// INPUT of a Knowledge rescan — the workspace_root and the repo list the scan
/// was handed (the case-sensitive `workspace_root.join(repo)` happens per repo).
#[derive(Serialize)]
struct ScanIn {
    workspace_root: String,
    repos: Vec<String>,
}

/// OUTPUT data for one scanned repo — resolved path, whether it exists on disk
/// (a case mismatch like `njord` vs `Njord` shows up as `exists:false`), the
/// per-kind counts, timing, and the error if the scan failed.
#[derive(Serialize)]
struct RepoScan {
    repo: String,
    path: String,
    exists: bool,
    symbols: usize,
    calls: usize,
    features: usize,
    git_files: usize,
    ms: u64,
    error: Option<String>,
}

/// OUTPUT summary of a rescan across all repos.
#[derive(Serialize)]
struct ScanOut {
    repos_ok: usize,
    repos_total: usize,
    ms: u64,
}

/// The rendered table payload — the exact per-crate rows the user sees.
#[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,
    /// Set when a rescan produces fresh `scans`; gates the `knowledge.render`
    /// trace event so it fires once per data change, not once per frame (the
    /// tab redraws at ~60fps — emitting render every frame would flood the
    /// trace). Cleared after the render event is emitted.
    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,
            render_dirty: false,
        }
    }

    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 {
            // Scan repos in parallel — each scan_all is an independent,
            // CPU-bound symbol-parse + git-history walk, so a workspace of
            // dozens of crates goes from sum-of-scans to slowest-scan. rayon's
            // collect preserves input order, so the bubble layout stays stable.
            use rayon::prelude::*;
            let workspace_root = &self.workspace_root;
            // Structured observability (see viz::trace): emit the INPUT (the
            // workspace_root + the repo list this scan was handed) and, per repo,
            // the OUTPUT data (resolved path, exists?, counts, timing, or the
            // error). A GUI has no console scrollback, so this is how an agent /
            // the test matrix sees a click turn into data — at $NORNIR_VIZ_TRACE.
            let t0 = Instant::now();
            trace::emit_in(
                "knowledge.scan",
                &ScanIn {
                    workspace_root: workspace_root.display().to_string(),
                    repos: self.repos.clone(),
                },
            );
            self.scans = self
                .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()),
                        },
                    };
                    // The OUTPUT data for this repo, structured (a missing path or
                    // a case-mismatch shows as exists=false + an error string).
                    trace::emit_end("knowledge.scan.repo", &result);
                    (repo.clone(), scan)
                })
                .collect();
            let ok = self.scans.iter().filter(|(_, s)| s.is_ok()).count();
            trace::emit_out(
                "knowledge.scan",
                &ScanOut {
                    repos_ok: ok,
                    repos_total: self.scans.len(),
                    ms: t0.elapsed().as_millis() as u64,
                },
            );
            self.last_scan = Some(Instant::now());
            self.rescan_requested = false;
            self.render_dirty = true;
        }

        let bubbles = collect_bubbles(&self.scans);
        // The rendered table/chart payload — the EXACT rows the user sees on
        // screen, as structured data. This is the "see the data we see" event:
        // an agent reads this back to know precisely what the Knowledge tab
        // displayed (which repos, which crates, the counts), no screenshot.
        // Edge-triggered (only after a rescan) so it never floods the per-frame
        // redraw loop.
        if self.render_dirty {
            trace::emit_end(
                "knowledge.render",
                &RenderOut { crates: bubbles.len(), bubbles: &bubbles },
            );
            self.render_dirty = false;
        }
        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);
    }
}

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

        // 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) {}