nornir 0.4.12

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Rectangular dep-graph renderer (🔗 Dep Graph tab).
//!
//! Nodes (repos) are **rectangular** cards laid out by topological rank — a
//! repo's dependencies sit to its right, consumers to its left — from the
//! pure-data [`DepGraphLayout`] (`super::depgraph_layout`), which also computes
//! the deep transitive closure (C5) and classifies every edge **direct** vs
//! **transitive** (C7).
//!
//! Navigable canvas (I3):
//!   * **pan** — drag the background, or scroll;
//!   * **zoom** — Ctrl/Cmd + scroll (or the +/− buttons);
//!   * **click a node** — toggle collapse/expand of its transitive subtree;
//!   * **deep toggle** — explode to the full transitive closure.
//!
//! The whole graph (nodes, positions, classified edges, collapse set) is mirrored
//! into `state_json` via [`DepGraphView::state_json`] (LAW #6), so the headless
//! snapshot/robot tests assert on the exact structure painted.

use std::collections::BTreeSet;

use eframe::egui::{
    self, Align2, Color32, FontId, Pos2, Rect, CornerRadius, Sense, Stroke, Ui, Vec2,
};
use uuid::Uuid;

use super::depgraph_layout::{DepGraphLayout, EdgeClass};
use super::model::Timeline;

const NODE_W: f32 = 160.0;
const NODE_H: f32 = 56.0;
const COL_GAP: f32 = 220.0;
const ROW_GAP: f32 = 90.0;
const LEFT_PAD: f32 = 30.0;
const TOP_PAD: f32 = 40.0;

// Edge palette (C7) — direct deps are a bright violet, transitive deps a dimmer,
// cooler hue so the eye separates "A depends on B" from "A reaches C through B".
const DIRECT_COLOR: Color32 = Color32::from_rgb(190, 140, 230);
const TRANSITIVE_COLOR: Color32 = Color32::from_rgb(90, 120, 165);

/// Persistent view state for the 🔗 Dep Graph tab (owned by the app). Carries the
/// navigation transform (pan/zoom), the C5 **deep** toggle, the I3 collapse set,
/// and the last laid-out graph so its structure can be dumped to `state_json`.
#[derive(Default)]
pub struct DepGraphView {
    /// C5: explode to the full transitive closure when true.
    pub deep: bool,
    /// Pan offset (screen px) applied to the whole graph.
    pub pan: Vec2,
    /// Zoom factor (1.0 = 100%).
    pub zoom: f32,
    /// I3: repos the user has collapsed (their exclusive subtree hidden).
    pub collapsed: BTreeSet<String>,
}

impl DepGraphView {
    pub fn new() -> Self {
        Self { deep: false, pan: Vec2::ZERO, zoom: 1.0, collapsed: BTreeSet::new() }
    }

    /// The laid-out graph as JSON for the introspection dump, computed fresh
    /// from `tl` + the current view settings (deep + collapse). Used by
    /// `state_json` so the headless matrix sees the structure without a draw.
    /// Returns `Null` when there is no snapshot to lay out.
    pub fn state_json_for(&self, tl: &Timeline, selected_release: Option<Uuid>) -> serde_json::Value {
        self.layout_for(tl, selected_release)
            .map(|l| l.state_json())
            .unwrap_or(serde_json::Value::Null)
    }

    fn layout_for(&self, tl: &Timeline, selected_release: Option<Uuid>) -> Option<DepGraphLayout> {
        let release = selected_release.or_else(|| tl.release_order.last().copied())?;
        let snapshot = tl.snapshot_for(&release)?;
        let repos: Vec<String> = tl.lanes.iter().map(|l| l.repo.clone()).collect();
        Some(DepGraphLayout::build(&repos, &snapshot.edges, self.deep, &self.collapsed))
    }
}

/// Draw the dep-graph tab. `selected_repo` is the app's drill-down selection
/// (kept so the existing right-panel keeps working). Returns nothing; mutates
/// `view` (pan/zoom/collapse/deep) and `selected_repo` from interaction.
pub fn draw_dep_graph(
    ui: &mut Ui,
    tl: &Timeline,
    selected_release: Option<Uuid>,
    selected_repo: &mut Option<String>,
    view: &mut DepGraphView,
) {
    if view.zoom <= 0.0 {
        view.zoom = 1.0;
    }

    // ── toolbar: deep toggle, zoom, reset, legend ──────────────────────────
    ui.horizontal(|ui| {
        let deep_label = if view.deep { "🌳 deep: ON" } else { "🌳 deep: off" };
        if ui
            .selectable_label(view.deep, deep_label)
            .on_hover_text("explode to the FULL transitive closure (C5)")
            .clicked()
        {
            view.deep = !view.deep;
        }
        ui.separator();
        if ui.button("").on_hover_text("zoom out").clicked() {
            view.zoom = (view.zoom * 0.9).max(0.25);
        }
        ui.label(format!("{:.0}%", view.zoom * 100.0));
        if ui.button("").on_hover_text("zoom in").clicked() {
            view.zoom = (view.zoom * 1.1).min(4.0);
        }
        if ui.button("⟲ reset view").on_hover_text("reset pan + zoom").clicked() {
            view.pan = Vec2::ZERO;
            view.zoom = 1.0;
        }
        if !view.collapsed.is_empty()
            && ui.button(format!("expand all ({})", view.collapsed.len()))
                .on_hover_text("re-expand every collapsed node")
                .clicked()
        {
            view.collapsed.clear();
        }
        ui.separator();
        // Legend (C7).
        legend_swatch(ui, DIRECT_COLOR, "direct dep");
        legend_swatch(ui, TRANSITIVE_COLOR, "transitive dep");
    });

    let Some(layout) = view.layout_for(tl, selected_release) else {
        ui.label("no dependency-graph snapshot for this release");
        return;
    };

    // ── canvas ─────────────────────────────────────────────────────────────
    let n_cols = layout.nodes.iter().map(|n| n.col).max().unwrap_or(0) + 1;
    let n_rows = layout.nodes.iter().map(|n| n.row).max().unwrap_or(0) + 1;
    let available = ui.available_size();
    let content_w = LEFT_PAD * 2.0 + n_cols as f32 * COL_GAP + NODE_W;
    let content_h = TOP_PAD * 2.0 + n_rows as f32 * ROW_GAP + NODE_H;
    let canvas = Vec2::new(available.x.max(400.0), available.y.max(360.0));
    let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click_and_drag());
    let painter = ui.painter_at(rect);
    painter.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(18, 18, 24));

    // ── pan + zoom interaction (I3) ──────────────────────────────────────────
    if resp.dragged() {
        view.pan += resp.drag_delta();
    }
    let hovered = resp.hovered();
    if hovered {
        ui.input(|i| {
            // scroll → pan; Ctrl/Cmd+scroll → zoom about the cursor.
            let scroll = i.raw_scroll_delta;
            if i.modifiers.command || i.modifiers.ctrl {
                let factor = (1.0 + scroll.y * 0.0015).clamp(0.5, 2.0);
                view.zoom = (view.zoom * factor).clamp(0.25, 4.0);
            } else if scroll != Vec2::ZERO {
                view.pan += scroll;
            }
        });
    }

    // world → screen: center the content, then apply pan + zoom.
    let base = rect.left_top().to_vec2() + Vec2::new(LEFT_PAD, TOP_PAD);
    let z = view.zoom;
    let offset = base + view.pan + Vec2::new(
        ((rect.width() - content_w * z).max(0.0)) / 2.0,
        ((rect.height() - content_h * z).max(0.0)) / 2.0,
    );
    let node_w = NODE_W * z;
    let node_h = NODE_H * z;
    let pos_of = |col: usize, row: usize| -> Pos2 {
        (offset + Vec2::new(col as f32 * COL_GAP * z, row as f32 * ROW_GAP * z)).to_pos2()
    };
    let node_pos = |repo: &str| -> Option<Pos2> {
        layout.position(repo).map(|(c, r)| pos_of(c, r))
    };
    let node_rect = |repo: &str| -> Option<Rect> {
        node_pos(repo).map(|p| Rect::from_min_size(p, Vec2::new(node_w, node_h)))
    };

    // ── edges first (under nodes), coloured by class (C7) ───────────────────
    for e in &layout.edges {
        let (Some(fr), Some(tr)) = (node_rect(&e.from), node_rect(&e.to)) else { continue };
        let from = fr.center();
        let to = tr.center();
        let color = match e.class {
            EdgeClass::Direct => DIRECT_COLOR,
            EdgeClass::Transitive => TRANSITIVE_COLOR,
        };
        let dashed = e.class == EdgeClass::Transitive;
        draw_arrow(&painter, from, to, color, node_w, dashed, z);
        // Label: via crates for a direct edge, hop count for a transitive one.
        let mid = Pos2::new((from.x + to.x) / 2.0, (from.y + to.y) / 2.0 - 8.0 * z);
        let label = match e.class {
            EdgeClass::Direct => {
                let via = e.via.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
                if e.via.len() > 2 { format!("{via} +{}", e.via.len() - 2) } else { via }
            }
            EdgeClass::Transitive => format!("{} hops", e.hops),
        };
        if z > 0.45 && !label.is_empty() {
            painter.text(
                mid,
                Align2::CENTER_BOTTOM,
                &label,
                FontId::monospace(10.0 * z.min(1.4)),
                if dashed { Color32::from_rgb(140, 160, 195) } else { Color32::from_rgb(190, 190, 210) },
            );
        }
    }

    // ── nodes (rectangular cards — KEEP the look) ───────────────────────────
    for node in &layout.nodes {
        let Some(nrect) = node_rect(&node.repo) else { continue };
        let lane = tl.lanes.iter().find(|l| l.repo == node.repo);
        let release = selected_release.or_else(|| tl.release_order.last().copied());
        let status = lane
            .and_then(|l| release.and_then(|rid| l.nodes.iter().find(|n| n.release_id == rid)))
            .map(|n| status_color(&n.gate_status))
            .unwrap_or(Color32::from_rgb(80, 80, 100));
        let is_sel = selected_repo.as_deref() == Some(node.repo.as_str());
        let fill = if is_sel { Color32::from_rgb(28, 46, 72) } else { Color32::from_rgb(30, 30, 40) };
        painter.rect_filled(nrect, CornerRadius::same(6), fill);
        let (bw, bc) = if is_sel {
            (3.0, Color32::from_rgb(120, 210, 255))
        } else if node.collapsed {
            (2.5, Color32::from_rgb(230, 180, 90))
        } else {
            (2.0, status)
        };
        painter.rect_stroke(nrect, CornerRadius::same(6), Stroke::new(bw, bc), egui::StrokeKind::Inside);
        // collapse marker (▸ collapsed / ▾ expanded) at the top-left.
        if z > 0.5 {
            painter.text(
                nrect.left_top() + Vec2::new(6.0, 4.0),
                Align2::LEFT_TOP,
                if node.collapsed { "" } else { "" },
                FontId::monospace(12.0),
                Color32::from_rgb(200, 200, 210),
            );
        }
        painter.text(
            nrect.center_top() + Vec2::new(0.0, 12.0 * z),
            Align2::CENTER_TOP,
            &node.repo,
            FontId::proportional(15.0 * z.clamp(0.6, 1.4)),
            Color32::WHITE,
        );
        if z > 0.55 {
            if let Some(n) = lane.and_then(|l| {
                release.and_then(|rid| l.nodes.iter().find(|n| n.release_id == rid))
            }) {
                painter.text(
                    nrect.center_top() + Vec2::new(0.0, 32.0 * z),
                    Align2::CENTER_TOP,
                    &n.sha[..n.sha.len().min(12)],
                    FontId::monospace(11.0 * z.clamp(0.6, 1.4)),
                    Color32::from_rgb(200, 200, 210),
                );
            }
        }
    }

    // ── click: a node toggles collapse/expand (I3); empty space deselects ───
    if resp.clicked() {
        if let Some(pos) = resp.interact_pointer_pos() {
            let hit = layout
                .nodes
                .iter()
                .map(|n| n.repo.clone())
                .find(|r| node_rect(r).is_some_and(|rc| rc.contains(pos)));
            if let Some(repo) = hit {
                // Select for the drill-down panel AND toggle collapse.
                *selected_repo = Some(repo.clone());
                if view.collapsed.contains(&repo) {
                    view.collapsed.remove(&repo);
                } else {
                    view.collapsed.insert(repo);
                }
            } else {
                *selected_repo = None;
            }
        }
    }

    // ── drill-down panel for the selected repo (unchanged behaviour) ────────
    if let (Some(repo), Some(release)) = (
        selected_repo.clone(),
        selected_release.or_else(|| tl.release_order.last().copied()),
    ) {
        if let Some(snap) = tl.snapshot_for(&release) {
            draw_drilldown(&painter, rect, &repo, snap, &layout);
        }
    }
}

fn legend_swatch(ui: &mut Ui, color: Color32, label: &str) {
    let (rect, _) = ui.allocate_exact_size(Vec2::new(22.0, 12.0), Sense::hover());
    ui.painter().rect_filled(rect, CornerRadius::same(2), color);
    ui.label(label);
}

fn draw_drilldown(
    painter: &egui::Painter,
    rect: Rect,
    repo: &str,
    snap: &crate::warehouse::dep_graph::DepGraphSnapshot,
    layout: &DepGraphLayout,
) {
    let deps: Vec<String> = snap
        .edges
        .iter()
        .filter(|e| e.from == repo)
        .map(|e| format!("{}   [{}]", e.to, e.via.iter().cloned().collect::<Vec<_>>().join(", ")))
        .collect();
    let users: Vec<String> = snap
        .edges
        .iter()
        .filter(|e| e.to == repo)
        .map(|e| format!("{}   [{}]", e.from, e.via.iter().cloned().collect::<Vec<_>>().join(", ")))
        .collect();
    let trans = DepGraphLayout::transitive_closure(repo, &snap.edges);
    let rows = 5 + deps.len() + users.len();
    let panel = Rect::from_min_size(
        rect.left_top() + Vec2::new(8.0, 8.0),
        Vec2::new(390.0, rows as f32 * 16.0 + 16.0),
    );
    painter.rect_filled(panel, CornerRadius::same(6), Color32::from_rgba_unmultiplied(16, 26, 42, 236));
    painter.rect_stroke(panel, CornerRadius::same(6), Stroke::new(1.0, Color32::from_rgb(80, 130, 180)), egui::StrokeKind::Inside);
    let mut y = panel.top() + 8.0;
    let mut put = |s: &str, c: Color32| {
        painter.text(Pos2::new(panel.left() + 10.0, y), Align2::LEFT_TOP, s, FontId::proportional(12.0), c);
        y += 16.0;
    };
    let collapsed = layout.nodes.iter().any(|n| n.repo == repo && n.collapsed);
    put(
        &format!("{repo}  (click node to {})", if collapsed { "expand" } else { "collapse" }),
        Color32::from_rgb(120, 210, 255),
    );
    put(&format!("direct deps ({}):", deps.len()), DIRECT_COLOR);
    for d in &deps {
        put(d, Color32::from_rgb(205, 215, 235));
    }
    put(
        &format!("transitive closure ({}): {}", trans.len(), trans.iter().cloned().collect::<Vec<_>>().join(", ")),
        TRANSITIVE_COLOR,
    );
    put(&format!("used by ({}):", users.len()), Color32::from_rgb(150, 185, 215));
    for u in &users {
        put(u, Color32::from_rgb(205, 215, 235));
    }
}

fn status_color(status: &str) -> Color32 {
    match status {
        s if s.starts_with("succeeded_dry_run") => Color32::from_rgb(200, 160, 60),
        s if s.starts_with("succeeded") => Color32::from_rgb(80, 180, 120),
        "failed_test" => Color32::from_rgb(220, 80, 80),
        "failed_bench" => Color32::from_rgb(220, 130, 60),
        _ => Color32::from_rgb(140, 140, 160),
    }
}

/// Arrow from card-center to card-center, stopping at the target card's edge.
/// `dashed` draws a transitive edge as a dashed line.
fn draw_arrow(
    painter: &egui::Painter,
    from: Pos2,
    to: Pos2,
    color: Color32,
    node_w: f32,
    dashed: bool,
    z: f32,
) {
    let dir = (to - from).normalized();
    let tip = to - dir * (node_w / 2.0 + 4.0);
    let stroke = Stroke::new(2.0 * z.clamp(0.6, 1.5), color);
    if dashed {
        // simple manual dashes
        let total = (tip - from).length();
        let step = 10.0 * z;
        let mut t = 0.0;
        while t < total {
            let a = from + dir * t;
            let b = from + dir * (t + step * 0.6).min(total);
            painter.line_segment([a, b], stroke);
            t += step;
        }
    } else {
        painter.line_segment([from, tip], stroke);
    }
    // arrowhead
    let perp = Vec2::new(-dir.y, dir.x);
    let base = tip - dir * 10.0 * z;
    let a = base + perp * 5.0 * z;
    let b = base - perp * 5.0 * z;
    painter.add(egui::Shape::convex_polygon(vec![tip, a, b], color, Stroke::NONE));
}