nornir 0.5.1

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! 🏢 **Warehouse Deck** — the whole Iceberg warehouse seen as MANY components at
//! once. Unlike the 🗄 Warehouse browser (one table → one grid), this tab hosts
//! a [`facett_warehousedeck::WarehouseDeck`]: a wall of N panes, each binding a
//! warehouse table (or a join) to a view-kind — several **3D graphs** (call graph,
//! dependency graph, …), grids, and charts, all visible simultaneously.
//!
//! nornir OWNS the warehouse + the viz; facett OWNS the rendering. This pane is
//! the glue: it opens the warehouse read-only, reads each table, maps the rows to
//! a facett view-model via [`WarehouseRegistry`], and drives the deck. modgunn's
//! security tables (`sbom_components`, `vuln_findings`, …) live in the same
//! `nornir` namespace and are rendered here exactly like nornir's own.
//!
//! Read-only open coexists with a running `nornir-server` (it falls back to a
//! catalog snapshot when the server holds the redb lock).

use std::path::PathBuf;

use arrow::array::{Array, StringArray};
use eframe::egui;

use facett_warehousedeck::{
    ChartSpec, Facet, GraphLayout, GraphSpec, PaneData, WarehouseDeck, WarehouseRegistry,
};

use crate::warehouse::iceberg::IcebergWarehouse;
use crate::warehouse::Warehouse;

use super::facett_theme::{Theme, RED};

/// Per-table row caps so a huge table never blows up a 3D layout or a grid.
const GRAPH_ROW_CAP: usize = 1500;
const GRID_ROW_CAP: usize = 500;
const CHART_ROW_CAP: usize = 200;

/// Where the deck reads from. Only local mode can build the 3D deck (it needs raw
/// Arrow frames); remote mode shows a hint pointing at the 🗄 Warehouse browser.
enum DeckSource {
    Local(PathBuf),
    Remote,
}

pub struct WarehouseDeckPane {
    source: DeckSource,
    registry: WarehouseRegistry,
    deck: Option<WarehouseDeck>,
    built: bool,
    err: Option<String>,
    /// Every table name found in the warehouse (for the state dump).
    tables: Vec<String>,
    theme: Theme,
}

impl WarehouseDeckPane {
    /// Build the deck from a local warehouse dir.
    pub fn local(root: PathBuf) -> Self {
        Self::with(DeckSource::Local(root))
    }

    /// Remote mode — the 3D deck currently requires local warehouse access.
    pub fn remote() -> Self {
        Self::with(DeckSource::Remote)
    }

    fn with(source: DeckSource) -> Self {
        Self {
            source,
            registry: WarehouseRegistry::default_nordisk(),
            deck: None,
            built: false,
            err: None,
            tables: Vec::new(),
            theme: Theme::default(),
        }
    }

    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// **Test seam** (mirrors the other viz panes' `inject_for_test`): set the deck
    /// directly, bypassing warehouse I/O, so a robot test asserts the render +
    /// `state_json` without seeding a real catalog.
    pub fn inject_for_test(&mut self, deck: WarehouseDeck) {
        self.tables = deck.titles().iter().map(|s| s.to_string()).collect();
        self.deck = Some(deck);
        self.err = None;
        self.built = true;
    }

    /// The registry that maps table → view-kind (extend with `register(...)`).
    pub fn registry_mut(&mut self) -> &mut WarehouseRegistry {
        &mut self.registry
    }

    /// 🏢 Warehouse Deck's slice of `state_json` (LAW #6): the composed deck state
    /// (pane count, per-kind counts, each pane's nodes/edges/rows + the per-pane
    /// widget state + the `trace.ran` ledger) plus this pane's error + the number
    /// of warehouse tables discovered.
    pub fn state_json(&self) -> serde_json::Value {
        match &self.deck {
            Some(d) => {
                let mut v = d.state_json();
                if let serde_json::Value::Object(map) = &mut v {
                    map.insert("error".into(), serde_json::json!(self.err));
                    map.insert("tables_in_warehouse".into(), serde_json::json!(self.tables.len()));
                    map.insert("built".into(), serde_json::json!(self.built));
                }
                v
            }
            None => serde_json::json!({
                "built": self.built,
                "error": self.err,
                "pane_count": 0,
                "graph3d_count": 0,
                "grid_count": 0,
                "chart_count": 0,
                "tables_in_warehouse": self.tables.len(),
            }),
        }
    }

    /// Open the warehouse read-only and build the deck. Curated initial slice:
    /// call graph + dependency graph (3D), the bench/test/security grids, and an
    /// event chart — each added only when its table exists with rows. Other tables
    /// fall back to the registry's per-table default.
    fn build(&mut self) {
        self.built = true;
        let root = match &self.source {
            DeckSource::Local(p) => p.clone(),
            DeckSource::Remote => {
                self.err = Some(
                    "the 🏢 Warehouse Deck (3D) needs local warehouse access — use the \
                     🗄 Warehouse browser when pointed at a remote server"
                        .into(),
                );
                return;
            }
        };
        let wh = match IcebergWarehouse::open_read_only(&root) {
            Ok(w) => w,
            Err(e) => {
                self.err = Some(format!(
                    "open warehouse failed: {e:#}\n(a running nornir-server holds the redb lock; \
                     a snapshot fallback is used when possible)"
                ));
                return;
            }
        };
        let tables = wh.table_names().unwrap_or_default();
        self.tables = tables.clone();
        let has = |t: &str| tables.iter().any(|x| x == t);

        let mut deck = WarehouseDeck::new("🏢 Warehouse Deck").with_cols(2);

        // ── 3D GRAPHS (joins) ────────────────────────────────────────────────
        if has("call_edges") {
            if let Some(g) = edge_graph(&wh, "call_edges", "caller_path", "callee_ident", GraphLayout::Force) {
                deck.add_for("call graph", "call_edges", PaneData::Graph(g));
            }
        }
        if has("dep_graph_edges") {
            if let Some(g) = edge_graph(&wh, "dep_graph_edges", "from_repo", "to_repo", GraphLayout::Sphere) {
                deck.add_for("dependency graph", "dep_graph_edges", PaneData::Graph(g));
            }
        }
        if has("scip_call_edges") {
            if let Some(g) = edge_graph(&wh, "scip_call_edges", "caller_symbol", "callee_symbol", GraphLayout::Force) {
                deck.add_for("resolved call graph", "scip_call_edges", PaneData::Graph(g));
            }
        }

        // ── GRIDS (tabular) ──────────────────────────────────────────────────
        for t in ["bench_runs", "test_outcomes", "vuln_findings", "sbom_components"] {
            if !has(t) {
                continue;
            }
            if let Ok(batches) = wh.scan_limited(t, GRID_ROW_CAP) {
                let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
                if rows > 0 {
                    deck.add_for(t, t, PaneData::Grid(batches));
                }
            }
        }

        // ── CHART (event/time-series) ────────────────────────────────────────
        for t in ["git_heat_facts", "bench_telemetry", "release_lineage"] {
            if !has(t) {
                continue;
            }
            if let Some(c) = numeric_chart(&wh, t) {
                deck.add_for(t, t, PaneData::Chart(c));
                break;
            }
        }

        if deck.pane_count() == 0 {
            self.err = Some(
                "no renderable tables yet — populate the workspace (nornir populate) so \
                 call_edges / dep_graph_edges / bench_runs accrue rows"
                    .into(),
            );
        }
        self.deck = Some(deck);
    }

    pub fn draw(&mut self, ui: &mut egui::Ui) {
        if !self.built {
            self.build();
        }
        ui.horizontal(|ui| {
            ui.heading("🏢 Warehouse Deck");
            ui.weak(format!("· {} tables in warehouse", self.tables.len()));
            if ui.button("↻ rebuild").clicked() {
                self.built = false;
                self.deck = None;
            }
        });
        ui.separator();
        if let Some(err) = &self.err {
            ui.colored_label(RED, err);
        }
        if let Some(deck) = &mut self.deck {
            deck.ui(ui);
        }
    }
}

/// Build a 3D graph from an edge table's `src`/`dst` string columns. Distinct
/// endpoints become nodes (coloured by [`facett_warehousedeck::hash_color`]); each
/// row an edge. `None` if the table is empty / lacks the columns.
fn edge_graph(wh: &IcebergWarehouse, table: &str, src: &str, dst: &str, layout: GraphLayout) -> Option<GraphSpec> {
    let batches = wh.scan_limited(table, GRAPH_ROW_CAP).ok()?;
    let mut idx: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
    let mut nodes: Vec<(String, egui::Color32)> = Vec::new();
    let mut edges: Vec<(usize, usize)> = Vec::new();
    for b in &batches {
        let (Ok(si), Ok(di)) = (b.schema().index_of(src), b.schema().index_of(dst)) else {
            continue;
        };
        let (Some(sc), Some(dc)) = (
            b.column(si).as_any().downcast_ref::<StringArray>(),
            b.column(di).as_any().downcast_ref::<StringArray>(),
        ) else {
            continue;
        };
        for r in 0..b.num_rows() {
            if sc.is_null(r) || dc.is_null(r) {
                continue;
            }
            let s = intern(sc.value(r), &mut nodes, &mut idx);
            let d = intern(dc.value(r), &mut nodes, &mut idx);
            edges.push((s, d));
        }
    }
    if nodes.is_empty() {
        return None;
    }
    Some(GraphSpec::new(nodes, edges).with_layout(layout))
}

/// Intern a node label → stable index, creating + colouring it on first sight.
fn intern(
    label: &str,
    nodes: &mut Vec<(String, egui::Color32)>,
    idx: &mut std::collections::HashMap<String, usize>,
) -> usize {
    if let Some(&i) = idx.get(label) {
        return i;
    }
    let i = nodes.len();
    nodes.push((label.to_string(), facett_warehousedeck::hash_color(label)));
    idx.insert(label.to_string(), i);
    i
}

/// Build a bars chart from a table's first numeric column (value) vs row index.
fn numeric_chart(wh: &IcebergWarehouse, table: &str) -> Option<ChartSpec> {
    use arrow::array::{Float64Array, Int32Array, Int64Array};
    let batches = wh.scan_limited(table, CHART_ROW_CAP).ok()?;
    let first = batches.first()?;
    // Find the first numeric column.
    let (col, name) = first.schema().fields().iter().enumerate().find_map(|(i, f)| {
        use arrow::datatypes::DataType::*;
        matches!(f.data_type(), Int64 | Int32 | Float64).then(|| (i, f.name().clone()))
    })?;
    let mut points: Vec<(f64, f64)> = Vec::new();
    let mut x = 0.0;
    for b in &batches {
        if b.num_columns() <= col {
            continue;
        }
        let column = b.column(col);
        for r in 0..b.num_rows() {
            let y = if let Some(a) = column.as_any().downcast_ref::<Int64Array>() {
                a.is_valid(r).then(|| a.value(r) as f64)
            } else if let Some(a) = column.as_any().downcast_ref::<Int32Array>() {
                a.is_valid(r).then(|| a.value(r) as f64)
            } else if let Some(a) = column.as_any().downcast_ref::<Float64Array>() {
                a.is_valid(r).then(|| a.value(r))
            } else {
                None
            };
            if let Some(y) = y {
                points.push((x, y));
                x += 1.0;
            }
        }
    }
    if points.is_empty() {
        return None;
    }
    Some(ChartSpec::bars(vec![(name, points)]))
}