holger-ui 0.1.4

Operator/admin UI for holger over the HolgerObject core API — egui via facett, embedded (LocalHolger, direct core calls) or remote (RemoteHolger gRPC).
//! The dependency-graph view types for the holger UI — the neon node-kind palette
//! ([`GraphNodeKind`]), the repo→kind classifier ([`repo_node_kind`]), and the
//! Móðguðr verdict-graph renderer ([`render_security_graph`]). Split out of `app`;
//! the `HolgerUiApp` methods that BUILD the `Graph3D` (`build_graph` / `graph_tab` /
//! `graph_state_json`) stay in `app` and reach these as `super::graph` items.

use eframe::egui;
use egui::{RichText, Ui};
use facett::graph::{DepEdge, DepGraphView, DepNode};

use crate::data::{DepGraphData, RepoRow};

/// Render the Móðguðr dependency-graph view: every scanned component as a node
/// coloured by verdict (block=red, warn=yellow, pass=green) via facett's
/// `DepGraphView`, with warehouse-sourced edges when present — else the coloured
/// node set with an honest "no resolved graph for this archive" note. A free
/// function (not a method) so it borrows only the graph data, leaving the Security
/// pane's other `self` field borrows intact.
pub(super) fn render_security_graph(graph: &DepGraphData, ui: &mut Ui) {
    if graph.nodes.is_empty() {
        ui.label("no components to graph.");
        return;
    }
    if !graph.has_edges {
        ui.label(
            RichText::new(
                "no resolved dependency graph for this archive — showing components coloured by verdict",
            )
            .weak(),
        );
    }
    ui.label(format!(
        "nodes {} · block {} · warn {} · pass {}",
        graph.nodes.len(),
        graph.block,
        graph.warn,
        graph.pass,
    ));
    let nodes: Vec<DepNode> = graph
        .nodes
        .iter()
        .map(|n| {
            let color = match n.decision.as_str() {
                "block" => egui::Color32::from_rgb(255, 80, 80),
                "warn" => egui::Color32::from_rgb(240, 200, 90),
                _ => egui::Color32::from_rgb(90, 220, 140),
            };
            let sublabel = if n.ids.is_empty() {
                n.version.clone()
            } else {
                format!("{} · {}", n.version, n.ids.join(", "))
            };
            DepNode { label: n.name.clone(), sublabel: Some(sublabel), color }
        })
        .collect();
    let edges: Vec<DepEdge> =
        graph.edges.iter().map(|(f, t)| DepEdge { from: *f, to: *t, via: Vec::new() }).collect();
    DepGraphView::new(nodes, edges)
        .with_title("Móðguðr — dependency verdict graph")
        .show(ui);
}

/// A node kind in the holger 3D graph — drives the **HOLGER NEON PALETTE**
/// (§"holger wiring" in `graph3d-orgasmic.md`). Each kind maps to a neon colour;
/// the facett `Graph3D` then layers glow/shockwave/dim over it under a Full-effects
/// theme (`DecorPolicy::Full`). The kind is also surfaced in `state_json` so the
/// robot can assert the graph as DATA, not pixels.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum GraphNodeKind {
    /// The holger server — the anchor (cyan/ice).
    Server,
    /// A repository (emerald→mint).
    Repository,
    /// An artifact / archive file (gold→lime).
    Artifact,
    /// An upstream / proxy target (tangerine).
    Upstream,
    /// A read-only / drifted repository (royal purple).
    ReadOnly,
    /// A quarantined / bad repository (crimson→neon-pink, pulses under Full).
    Quarantined,
}

impl GraphNodeKind {
    /// The neon base colour for this kind. Under `DecorPolicy::Full` the facett
    /// kernel re-shades with its neon gradient + glow; under `Reduced`/`device`
    /// these kind-colours are what the operator sees (so the mapping is real, not
    /// cosmetic).
    pub(super) fn color(self) -> egui::Color32 {
        match self {
            // cyan / ice — the anchor
            GraphNodeKind::Server => egui::Color32::from_rgb(120, 220, 255),
            // emerald → mint
            GraphNodeKind::Repository => egui::Color32::from_rgb(80, 230, 160),
            // gold → lime
            GraphNodeKind::Artifact => egui::Color32::from_rgb(210, 230, 90),
            // tangerine
            GraphNodeKind::Upstream => egui::Color32::from_rgb(255, 160, 70),
            // royal purple
            GraphNodeKind::ReadOnly => egui::Color32::from_rgb(150, 110, 240),
            // crimson → neon-pink (the PULSE kind)
            GraphNodeKind::Quarantined => egui::Color32::from_rgb(255, 60, 130),
        }
    }

    /// A stable string label for `state_json` (asserted by the robot).
    pub(super) fn label(self) -> &'static str {
        match self {
            GraphNodeKind::Server => "server",
            GraphNodeKind::Repository => "repository",
            GraphNodeKind::Artifact => "artifact",
            GraphNodeKind::Upstream => "upstream",
            GraphNodeKind::ReadOnly => "read-only",
            GraphNodeKind::Quarantined => "quarantined",
        }
    }
}

/// Classify a repository row into a [`GraphNodeKind`] from the data the UI already
/// exposes (no new gRPC call): quarantine/drift markers in the name or type, then
/// proxy/upstream/cache type, then the read-only flag, else a plain repository.
pub(super) fn repo_node_kind(r: &RepoRow) -> GraphNodeKind {
    let t = r.repo_type.to_ascii_lowercase();
    let n = r.name.to_ascii_lowercase();
    if n.contains("quarant") || t.contains("quarant") || t.contains("bad") || t.contains("drift") {
        GraphNodeKind::Quarantined
    } else if t.contains("proxy") || t.contains("upstream") || t.contains("remote") || n.contains("cache") || t.contains("cache") {
        GraphNodeKind::Upstream
    } else if !r.writable {
        GraphNodeKind::ReadOnly
    } else {
        GraphNodeKind::Repository
    }
}