nornir 0.1.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Force-free dep-graph renderer.
//!
//! Nodes (repos) are laid out by build order — root-most (`build_order_idx`
//! 0 in the most recent release) on the left, leaves on the right. The
//! current release is given to position consumers/producers; if it's
//! absent we fall back to alphabetical placement.
//!
//! Edges from the pinned dep-graph snapshot are drawn as arrows with
//! the via-crate list shown on hover.

use std::collections::BTreeMap;

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

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;

pub fn draw_dep_graph(ui: &mut Ui, tl: &Timeline, selected_release: Option<Uuid>) {
    let release = selected_release.or_else(|| tl.release_order.last().copied());
    let Some(release_id) = release else {
        ui.label("no releases recorded yet");
        return;
    };

    let snapshot = tl.snapshot_for(&release_id);

    // 1. compute layout: column = build_order_idx if we know it, row 0
    //    by default. Stack multiple repos with the same column into rows.
    let mut order_by_repo: BTreeMap<&str, usize> = BTreeMap::new();
    for lane in &tl.lanes {
        if let Some(n) = lane.nodes.iter().find(|n| n.release_id == release_id) {
            // build_order isn't on LaneNode; we recover it from the
            // lane's position among ordered repos for that release.
            let _ = n;
        }
    }
    // Sort by lane.repo name as a stable default; if we have a snapshot
    // we toposort from the edges (deps → dependents left-to-right).
    let mut repos: Vec<&str> = tl.lanes.iter().map(|l| l.repo.as_str()).collect();
    repos.sort();
    if let Some(snap) = snapshot {
        repos = toposort(&repos, &snap.edges);
    }
    for (i, r) in repos.iter().enumerate() {
        order_by_repo.insert(r, i);
    }

    let available = ui.available_size();
    let needed_w = LEFT_PAD + repos.len() as f32 * COL_GAP + NODE_W;
    let canvas = Vec2::new(available.x.max(needed_w), available.y.max(360.0));
    let (rect, _resp) = ui.allocate_exact_size(canvas, Sense::hover());
    let painter = ui.painter_at(rect);
    painter.rect_filled(rect, Rounding::ZERO, Color32::from_rgb(18, 18, 24));

    let row_for = |_repo: &str| 0usize;
    let pos_for = |repo: &str| -> Pos2 {
        let col = *order_by_repo.get(repo).unwrap_or(&0);
        let row = row_for(repo);
        Pos2::new(
            rect.left() + LEFT_PAD + col as f32 * COL_GAP,
            rect.top() + TOP_PAD + row as f32 * ROW_GAP,
        )
    };

    // 2. draw edges first (under nodes)
    if let Some(snap) = snapshot {
        for e in &snap.edges {
            let (Some(_), Some(_)) = (
                order_by_repo.get(e.from.as_str()),
                order_by_repo.get(e.to.as_str()),
            ) else {
                continue;
            };
            let from = pos_for(&e.from) + Vec2::new(NODE_W / 2.0, NODE_H / 2.0);
            let to = pos_for(&e.to) + Vec2::new(NODE_W / 2.0, NODE_H / 2.0);
            draw_arrow(&painter, from, to, Color32::from_rgb(180, 140, 220));
            let mid = Pos2::new((from.x + to.x) / 2.0, (from.y + to.y) / 2.0 - 8.0);
            let via = e.via.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
            let label = if e.via.len() > 2 {
                format!("{via} +{}", e.via.len() - 2)
            } else {
                via
            };
            painter.text(
                mid,
                Align2::CENTER_BOTTOM,
                &label,
                FontId::monospace(10.0),
                Color32::from_rgb(190, 190, 210),
            );
        }
    }

    // 3. draw nodes
    for lane in &tl.lanes {
        let node_pos = pos_for(&lane.repo);
        let node_rect = Rect::from_min_size(node_pos, Vec2::new(NODE_W, NODE_H));
        let status_color = lane
            .nodes
            .iter()
            .find(|n| n.release_id == release_id)
            .map(|n| status_color(&n.gate_status))
            .unwrap_or(Color32::from_rgb(80, 80, 100));
        painter.rect_filled(node_rect, Rounding::same(6.0), Color32::from_rgb(30, 30, 40));
        painter.rect_stroke(node_rect, Rounding::same(6.0), Stroke::new(2.0, status_color));
        painter.text(
            node_pos + Vec2::new(NODE_W / 2.0, 14.0),
            Align2::CENTER_TOP,
            &lane.repo,
            FontId::proportional(15.0),
            Color32::WHITE,
        );
        if let Some(n) = lane.nodes.iter().find(|n| n.release_id == release_id) {
            painter.text(
                node_pos + Vec2::new(NODE_W / 2.0, 34.0),
                Align2::CENTER_TOP,
                &n.sha[..n.sha.len().min(12)],
                FontId::monospace(11.0),
                Color32::from_rgb(200, 200, 210),
            );
        }
    }
}

const LEFT_PAD: f32 = 30.0;
const TOP_PAD: f32 = 40.0;

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),
    }
}

fn draw_arrow(painter: &egui::Painter, from: Pos2, to: Pos2, color: Color32) {
    let stroke = Stroke::new(2.0, color);
    painter.line_segment([from, to], stroke);
    // arrowhead
    let dir = (to - from).normalized();
    let perp = Vec2::new(-dir.y, dir.x);
    let tip = to - dir * (NODE_W / 2.0 + 4.0);
    let base = tip - dir * 10.0;
    let a = base + perp * 5.0;
    let b = base - perp * 5.0;
    painter.add(egui::Shape::convex_polygon(vec![tip, a, b], color, Stroke::NONE));
}

fn toposort<'a>(
    repos: &[&'a str],
    edges: &'a [crate::warehouse::dep_graph::CrossRepoEdge],
) -> Vec<&'a str> {
    use std::collections::{BTreeMap, BTreeSet, VecDeque};
    let set: BTreeSet<&str> = repos.iter().copied().collect();
    let mut indeg: BTreeMap<&str, usize> = repos.iter().map(|r| (*r, 0)).collect();
    let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
    for e in edges {
        let from = e.from.as_str();
        let to = e.to.as_str();
        if !set.contains(from) || !set.contains(to) {
            continue;
        }
        // edge from -> to means `from` consumes `to`; we want `to`
        // first (leaves on the left? no — root deps first → idx 0).
        // Convention used by `WorkspaceGraph::build_order`: deps first.
        adj.entry(to).or_default().push(from);
        *indeg.entry(from).or_insert(0) += 1;
    }
    let mut q: VecDeque<&str> = indeg.iter().filter(|(_, d)| **d == 0).map(|(r, _)| *r).collect();
    let mut out = Vec::with_capacity(repos.len());
    while let Some(r) = q.pop_front() {
        out.push(r);
        if let Some(children) = adj.get(r) {
            for &c in children {
                let d = indeg.get_mut(c).unwrap();
                *d -= 1;
                if *d == 0 {
                    q.push_back(c);
                }
            }
        }
    }
    if out.len() == repos.len() {
        out
    } else {
        repos.to_vec()
    }
}