Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/viz/diagram.rs

//! Data-only diagram renderers for the viz tabs.
//!
//! Each viz tab paints a diagram with egui — on screen only. These are the
//! **pure-data counterparts**: headless functions that turn the same
//! [`Timeline`] data model into an exportable static SVG / Markdown diagram,
//! with **no egui dependency**. They are compiled wherever [`model`](super::model)
//! is (the `server` build too — see [`super`] module docs), so the docs
//! exporter, the `Viz.Timeline` RPC, and the CLI can all render the viz
//! diagrams without a display (CLI parity with the GUI).
//!
//! House style mirrors [`crate::introspect::Graph::to_svg`]: graph diagrams are
//! emitted as a self-contained static SVG (no JavaScript, no diagram engine, no
//! build step — never Mermaid), so they render verbatim in the Codeberg /
//! GitHub web UI; tabular diagrams are plain GitHub-flavoured Markdown tables.
//!
//! There is one renderer per **diagram-bearing** viz tab (the interactive /
//! tabular ops tabs — LiveRun, Warehouse, Mcp, Search — have no static diagram
//! to export). The eight:
//!
//! | renderer | viz tab | shape |
//! |----------|---------|-------|
//! | [`timeline_svg`]         | Timeline   | static SVG timeline (per-repo lanes) |
//! | [`lane_summary_table`]   | Timeline   | per-repo lane summary table |
//! | [`depgraph_svg`]         | DepGraph   | static SVG `graph LR`, edges labelled by `via` crates |
//! | [`snapshot_edge_list`]   | DepGraph   | cross-repo edge / adjacency list |
//! | [`gate_matrix_table`]    | Gates      | release × repo gate-verdict matrix |
//! | [`release_versions_table`] | Funnel   | published crate versions per release |
//! | [`bench_history_table`]  | Bench      | per-repo primary-metric trend (newest first) |
//! | [`bench_compare_table`]  | TimeTravel | latest bench metric across repos |

use std::collections::BTreeSet;
use std::fmt::Write as _;

use crate::warehouse::dep_graph::DepGraphSnapshot;

use super::model::{BenchHistory, Lane, Timeline};

/// XML-escape a label for safe inclusion in SVG text / attributes.
fn xml_escape(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

/// Short git sha (first 7 chars) for compact node/cell labels.
fn short_sha(sha: &str) -> &str {
    &sha[..sha.len().min(7)]
}

/// Pipe-escape a cell value so a stray `|` can't break a Markdown table.
fn cell(s: &str) -> String {
    s.replace('|', "\\|")
}

/// The latest published version for a lane node, if any (`crate v1.2.3` or just
/// the version when the crate name is empty).
fn node_version(versions: &[(String, String)]) -> String {
    versions
        .iter()
        .map(|(c, v)| {
            if c.is_empty() {
                format!("v{v}")
            } else {
                format!("{c} v{v}")
            }
        })
        .collect::<Vec<_>>()
        .join(", ")
}

// ── Timeline tab ────────────────────────────────────────────────────────────

/// **Timeline tab → static SVG timeline.** One lane (row) per repo, one cell
/// per recorded release (`sha` + gate verdict + published versions), laid out
/// left-to-right in release order. Empty lanes (bench-only repos) are skipped.
/// Self-contained SVG — no JavaScript, no diagram engine (never Mermaid).
/// Returns an italic placeholder when the timeline has no releases at all.
pub fn timeline_svg(tl: &Timeline) -> String {
    if !tl.has_releases() {
        return "_no releases recorded yet_\n".to_string();
    }
    let lanes: Vec<&Lane> = tl.lanes.iter().filter(|l| !l.nodes.is_empty()).collect();
    if lanes.is_empty() {
        return "_no releases recorded yet_\n".to_string();
    }
    let label_w = 150.0f64;
    let cell_w = 210.0f64;
    let cell_h = 30.0f64;
    let row_h = 40.0f64;
    let margin = 14.0f64;
    let title_h = 26.0f64;

    let max_cells = lanes
        .iter()
        .map(|l| l.nodes.len())
        .max()
        .unwrap_or(1)
        .max(1);
    let width = margin * 2.0 + label_w + cell_w * max_cells as f64;
    let height = margin * 2.0 + title_h + row_h * lanes.len() as f64;

    let mut s = String::new();
    let _ = write!(
        s,
        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.0}\" height=\"{height:.0}\" \
         viewBox=\"0 0 {width:.0} {height:.0}\" font-family=\"sans-serif\" font-size=\"11\">\n"
    );
    let _ = write!(
        s,
        "<text x=\"{:.0}\" y=\"{:.0}\" font-size=\"13\" font-weight=\"bold\">{} release timeline</text>\n",
        margin,
        margin + 14.0,
        xml_escape(&tl.workspace_name),
    );
    for (li, lane) in lanes.iter().enumerate() {
        let row_y = margin + title_h + li as f64 * row_h;
        let _ = write!(
            s,
            "<text x=\"{:.0}\" y=\"{:.0}\" font-weight=\"bold\">{}</text>\n",
            margin,
            row_y + cell_h / 2.0 + 4.0,
            xml_escape(&lane.repo),
        );
        for (ci, n) in lane.nodes.iter().enumerate() {
            let x = margin + label_w + ci as f64 * cell_w;
            let date = n.timestamp.format("%Y-%m-%d");
            let mut label = format!("{} {} {}", date, short_sha(&n.sha), n.gate_status);
            let ver = node_version(&n.published_versions);
            if !ver.is_empty() {
                label.push_str(" — ");
                label.push_str(&ver);
            }
            let fill = if n.gate_status.starts_with("succeeded") {
                "#e6f3ea"
            } else {
                "#f7e6e6"
            };
            let _ = write!(
                s,
                "<rect x=\"{x:.0}\" y=\"{row_y:.0}\" width=\"{:.0}\" height=\"{cell_h:.0}\" rx=\"3\" \
                 fill=\"{fill}\" stroke=\"#3c3c50\" stroke-width=\"0.7\"/>\n",
                cell_w - 8.0,
            );
            let _ = write!(
                s,
                "<text x=\"{:.0}\" y=\"{:.0}\">{}</text>\n",
                x + 6.0,
                row_y + cell_h / 2.0 + 4.0,
                xml_escape(&label),
            );
        }
    }
    s.push_str("</svg>\n");
    s
}

/// **Timeline tab → summary table.** One row per repo lane: number of recorded
/// releases, the newest release's sha / gate / version. The at-a-glance
/// counterpart to the swim-lane painting.
pub fn lane_summary_table(tl: &Timeline) -> String {
    if tl.lanes.is_empty() {
        return "_no repos in this workspace_\n".to_string();
    }
    let mut s = String::from("| Repo | Releases | Latest sha | Gate | Version |\n");
    s.push_str("|------|---------:|-----------|------|---------|\n");
    for lane in &tl.lanes {
        let (sha, gate, ver) = match lane.nodes.last() {
            Some(n) => (
                short_sha(&n.sha).to_string(),
                n.gate_status.clone(),
                node_version(&n.published_versions),
            ),
            None => ("—".into(), "—".into(), String::new()),
        };
        let ver = if ver.is_empty() {
            "—".to_string()
        } else {
            ver
        };
        let _ = writeln!(
            s,
            "| {} | {} | {} | {} | {} |",
            cell(&lane.repo),
            lane.nodes.len(),
            cell(&sha),
            cell(&gate),
            cell(&ver),
        );
    }
    s
}

// ── DepGraph tab ────────────────────────────────────────────────────────────

/// Pick the snapshot to draw: the explicitly latest one, else the
/// highest-`timestamp` snapshot in the map.
fn latest_snapshot(tl: &Timeline) -> Option<&DepGraphSnapshot> {
    tl.latest_snapshot
        .as_ref()
        .or_else(|| tl.snapshots.values().max_by_key(|s| s.timestamp))
}

/// **DepGraph tab → static SVG `graph LR`.** The cross-repo dependency
/// snapshot: one node per repo, one left-to-right edge `from → to` labelled
/// with the crates that justify it (`via`). Mirrors the woven-threads painting.
/// Self-contained SVG — no JavaScript, no diagram engine (never Mermaid).
/// Returns an italic placeholder when no snapshot was captured.
pub fn depgraph_svg(tl: &Timeline) -> String {
    let Some(snap) = latest_snapshot(tl) else {
        return "_no dependency-graph snapshot captured yet_\n".to_string();
    };
    // Build a layered-DAG layout via the shared introspect renderer for node
    // placement, but draw here so we can keep the per-edge `via` crate labels.
    let mut node_set: BTreeSet<&str> = BTreeSet::new();
    for e in &snap.edges {
        node_set.insert(e.from.as_str());
        node_set.insert(e.to.as_str());
    }
    let nodes: Vec<String> = node_set.iter().map(|s| s.to_string()).collect();
    use std::collections::HashMap;
    let idx: HashMap<&str, usize> = nodes
        .iter()
        .enumerate()
        .map(|(i, s)| (s.as_str(), i))
        .collect();

    // Layered columns by longest-path depth (sources at column 0). Cycle-safe:
    // a bounded relaxation pass over the edge set.
    let n = nodes.len();
    let mut col = vec![0usize; n];
    for _ in 0..n {
        let mut changed = false;
        for e in &snap.edges {
            if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
                if f != t && col[t] <= col[f] {
                    col[t] = col[f] + 1;
                    changed = true;
                }
            }
        }
        if !changed {
            break;
        }
    }
    let cols = col.iter().copied().max().unwrap_or(0) + 1;
    // Row within each column, assigned in node (sorted) order.
    let mut next_row = vec![0usize; cols];
    let mut row = vec![0usize; n];
    for i in 0..n {
        row[i] = next_row[col[i]];
        next_row[col[i]] += 1;
    }
    let rows = next_row.iter().copied().max().unwrap_or(1).max(1);

    let col_w = 200.0f64;
    let row_h = 44.0f64;
    let box_w = 150.0f64;
    let box_h = 26.0f64;
    let margin = 14.0f64;
    let width = margin * 2.0 + col_w * cols as f64;
    let height = margin * 2.0 + row_h * rows as f64;
    let pos = |i: usize| -> (f64, f64) {
        (
            margin + col[i] as f64 * col_w,
            margin + row[i] as f64 * row_h,
        )
    };

    let mut s = String::new();
    let _ = write!(
        s,
        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.0}\" height=\"{height:.0}\" \
         viewBox=\"0 0 {width:.0} {height:.0}\" font-family=\"sans-serif\" font-size=\"11\">\n"
    );
    s.push_str(
        "<defs><marker id=\"arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"7\" refY=\"3\" \
         orient=\"auto\"><path d=\"M0,0 L7,3 L0,6 Z\" fill=\"#5a6876\"/></marker></defs>\n",
    );
    for e in &snap.edges {
        let (Some(&fi), Some(&ti)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
            continue;
        };
        let (fx, fy) = pos(fi);
        let (tx, ty) = pos(ti);
        let x1 = fx + box_w;
        let y1 = fy + box_h / 2.0;
        let x2 = tx;
        let y2 = ty + box_h / 2.0;
        let _ = write!(
            s,
            "<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
             stroke=\"#787882\" stroke-width=\"1\" stroke-dasharray=\"4 3\" marker-end=\"url(#arrow)\"/>\n"
        );
        let via: Vec<&str> = e.via.iter().map(String::as_str).collect();
        if !via.is_empty() {
            let _ = write!(
                s,
                "<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\" fill=\"#5a6876\" font-size=\"9\">{}</text>\n",
                (x1 + x2) / 2.0,
                (y1 + y2) / 2.0 - 3.0,
                xml_escape(&via.join(", ")),
            );
        }
    }
    for (i, name) in nodes.iter().enumerate() {
        let (x, y) = pos(i);
        let _ = write!(
            s,
            "<rect x=\"{x:.0}\" y=\"{y:.0}\" width=\"{box_w:.0}\" height=\"{box_h:.0}\" rx=\"3\" \
             fill=\"#f5f7fc\" stroke=\"#3c3c50\" stroke-width=\"0.7\"/>\n"
        );
        let _ = write!(
            s,
            "<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\">{}</text>\n",
            x + box_w / 2.0,
            y + box_h / 2.0 + 4.0,
            xml_escape(name),
        );
    }
    s.push_str("</svg>\n");
    s
}

/// **DepGraph tab → edge list.** The same cross-repo snapshot as a flat,
/// plain Markdown list (`from → to (via crateA, crateB)`) for terminal output
/// or hosts that prefer text over the inline SVG. Sorted for deterministic output.
pub fn snapshot_edge_list(tl: &Timeline) -> String {
    let Some(snap) = latest_snapshot(tl) else {
        return "_no dependency-graph snapshot captured yet_\n".to_string();
    };
    if snap.edges.is_empty() {
        return "_no cross-repo dependency edges in the latest snapshot_\n".to_string();
    }
    let mut lines: Vec<String> = snap
        .edges
        .iter()
        .map(|e| {
            let via: Vec<&str> = e.via.iter().map(String::as_str).collect();
            if via.is_empty() {
                format!("- `{}` → `{}`", e.from, e.to)
            } else {
                format!("- `{}` → `{}` (via {})", e.from, e.to, via.join(", "))
            }
        })
        .collect();
    lines.sort();
    let mut s = lines.join("\n");
    s.push('\n');
    s
}

// ── Gates tab ───────────────────────────────────────────────────────────────

/// **Gates tab → verdict matrix.** Rows = recorded releases (chronological),
/// columns = repos, cells = the gate verdict (`✓` succeeded, `✗` failed,
/// otherwise the raw status). The tabular view of which release passed the gate
/// for which repo.
pub fn gate_matrix_table(tl: &Timeline) -> String {
    if !tl.has_releases() {
        return "_no releases recorded yet_\n".to_string();
    }
    // Column order = lane order (already repo-sorted in the model).
    let repos: Vec<&Lane> = tl.lanes.iter().filter(|l| !l.nodes.is_empty()).collect();
    if repos.is_empty() {
        return "_no gated releases recorded yet_\n".to_string();
    }
    let mut header = String::from("| Release |");
    let mut divider = String::from("|---------|");
    for l in &repos {
        let _ = write!(header, " {} |", cell(&l.repo));
        divider.push_str(":--:|");
    }
    let mut s = format!("{header}\n{divider}\n");

    for rid in &tl.release_order {
        // Find any node for this release to label the row by date.
        let label = repos
            .iter()
            .flat_map(|l| l.nodes.iter())
            .find(|n| &n.release_id == rid)
            .map(|n| n.timestamp.format("%Y-%m-%d %H:%M").to_string())
            .unwrap_or_else(|| rid.to_string());
        let mut row = format!("| {} |", cell(&label));
        for l in &repos {
            let mark = l
                .nodes
                .iter()
                .find(|n| &n.release_id == rid)
                .map(|n| gate_mark(&n.gate_status))
                .unwrap_or("·".to_string());
            let _ = write!(row, " {mark} |");
        }
        let _ = writeln!(s, "{row}");
    }
    s
}

fn gate_mark(status: &str) -> String {
    if status.starts_with("succeeded") {
        "✓".to_string()
    } else if status.starts_with("failed") {
        "✗".to_string()
    } else {
        cell(status)
    }
}

// ── Funnel tab ──────────────────────────────────────────────────────────────

/// **Funnel tab → published-versions table.** One row per (repo, release) that
/// published at least one crate, listing the crate@version it cut. The
/// release-output side of the funnel.
pub fn release_versions_table(tl: &Timeline) -> String {
    let mut rows: Vec<(String, String, String)> = Vec::new(); // (date, repo, versions)
    for lane in &tl.lanes {
        for n in &lane.nodes {
            if n.published_versions.is_empty() {
                continue;
            }
            rows.push((
                n.timestamp.format("%Y-%m-%d").to_string(),
                lane.repo.clone(),
                node_version(&n.published_versions),
            ));
        }
    }
    if rows.is_empty() {
        return "_no published versions recorded yet_\n".to_string();
    }
    rows.sort();
    let mut s = String::from("| Date | Repo | Published |\n");
    s.push_str("|------|------|-----------|\n");
    for (date, repo, ver) in rows {
        let _ = writeln!(s, "| {} | {} | {} |", date, cell(&repo), cell(&ver));
    }
    s
}

// ── Bench / TimeTravel tabs ─────────────────────────────────────────────────

/// **Bench tab → per-repo history table.** For each repo with bench points, the
/// N newest runs (newest first) of its primary metric — the textual sparkline.
/// `limit` caps rows per repo (`None` → all).
pub fn bench_history_table(tl: &Timeline, limit: Option<usize>) -> String {
    let mut histories: Vec<&BenchHistory> = tl
        .bench_history
        .values()
        .filter(|h| !h.points.is_empty())
        .collect();
    histories.sort_by(|a, b| a.repo.cmp(&b.repo));
    if histories.is_empty() {
        return "_no benchmark history recorded yet_\n".to_string();
    }
    let mut s = String::from("| Repo | Date | Version | Metric | Value | Machine |\n");
    s.push_str("|------|------|---------|--------|------:|---------|\n");
    for h in histories {
        let mut pts: Vec<_> = h.points.iter().collect();
        pts.sort_by_key(|p| std::cmp::Reverse(p.timestamp)); // newest first
        if let Some(n) = limit {
            pts.truncate(n);
        }
        for p in pts {
            let _ = writeln!(
                s,
                "| {} | {} | {} | {} | {} | {} |",
                cell(&h.repo),
                p.timestamp.format("%Y-%m-%d"),
                cell(&p.version),
                cell(&p.primary_metric_name),
                fmt_num(p.primary_metric_value),
                cell(&p.machine),
            );
        }
    }
    s
}

/// **TimeTravel tab → latest-bench comparison table.** One row per repo, its
/// most recent run's primary metric — the cross-repo "where does each stand
/// now" view that the TimeTravel reel scrubs through.
pub fn bench_compare_table(tl: &Timeline) -> String {
    let mut rows: Vec<(String, String, f64, String, String)> = Vec::new();
    for (repo, h) in &tl.bench_history {
        if let Some(p) = h.points.iter().max_by_key(|p| p.timestamp) {
            rows.push((
                repo.clone(),
                p.primary_metric_name.clone(),
                p.primary_metric_value,
                p.version.clone(),
                p.timestamp.format("%Y-%m-%d").to_string(),
            ));
        }
    }
    if rows.is_empty() {
        return "_no benchmark history recorded yet_\n".to_string();
    }
    rows.sort_by(|a, b| a.0.cmp(&b.0));
    let mut s = String::from("| Repo | Latest metric | Value | Version | Date |\n");
    s.push_str("|------|---------------|------:|---------|------|\n");
    for (repo, metric, val, ver, date) in rows {
        let _ = writeln!(
            s,
            "| {} | {} | {} | {} | {} |",
            cell(&repo),
            cell(&metric),
            fmt_num(val),
            cell(&ver),
            date,
        );
    }
    s
}

/// Compact numeric formatting: integers without a decimal point and with
/// thousands separators, fractions to 3 sig figs.
fn fmt_num(v: f64) -> String {
    if v.fract() == 0.0 && v.abs() < 1e15 {
        let i = v as i64;
        let s = i.abs().to_string();
        let mut out = String::new();
        for (idx, ch) in s.chars().enumerate() {
            if idx > 0 && (s.len() - idx).is_multiple_of(3) {
                out.push(',');
            }
            out.push(ch);
        }
        if i < 0 { format!("-{out}") } else { out }
    } else if v.abs() >= 1000.0 {
        format!("{v:.0}")
    } else {
        format!("{v:.3}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::warehouse::dep_graph::CrossRepoEdge;
    use chrono::{TimeZone, Utc};
    use std::collections::BTreeMap;
    use uuid::Uuid;

    fn sample_timeline() -> Timeline {
        let sid = Uuid::from_u128(1);
        let rid = Uuid::from_u128(2);
        let ts = Utc.with_ymd_and_hms(2026, 6, 10, 12, 0, 0).unwrap();
        let snap = DepGraphSnapshot {
            snapshot_id: sid,
            workspace_name: "skade".into(),
            timestamp: ts,
            edges: vec![CrossRepoEdge::normal(
                "skade",
                "skade-katalog",
                BTreeSet::from(["skade-katalog".to_string()]),
            )],
        };
        let mut snapshots = BTreeMap::new();
        snapshots.insert(sid, snap.clone());
        let mut release_snapshot = BTreeMap::new();
        release_snapshot.insert(rid, sid);

        let node = super::super::model::LaneNode {
            release_id: rid,
            timestamp: ts,
            sha: "deadbeefcafef00d".into(),
            branch: "main".into(),
            dirty: false,
            gate_status: "succeeded".into(),
            tests_passed: 9,
            tests_failed: 0,
            published_versions: vec![("skade".into(), "0.1.4".into())],
        };
        let mut bench_history = BTreeMap::new();
        bench_history.insert(
            "skade".to_string(),
            BenchHistory {
                repo: "skade".into(),
                points: vec![super::super::model::BenchPoint {
                    timestamp: ts,
                    primary_metric_name: "skade_table_exists_ops_sec".into(),
                    primary_metric_value: 2_000_000.0,
                    metrics: vec![("skade_table_exists.ops_sec".into(), 2_000_000.0)],
                    version: "0.1.4".into(),
                    machine: "oden".into(),
                }],
            },
        );

        Timeline {
            workspace_name: "skade".into(),
            lanes: vec![Lane {
                repo: "skade".into(),
                nodes: vec![node],
            }],
            release_order: vec![rid],
            release_snapshot,
            snapshots,
            latest_snapshot: Some(snap),
            bench_history,
        }
    }

    #[test]
    fn timeline_svg_has_lane_and_event() {
        let tl = sample_timeline();
        let out = timeline_svg(&tl);
        assert!(out.starts_with("<svg"), "{out}");
        assert!(out.contains("release timeline"), "{out}");
        assert!(out.contains(">skade<"), "lane label: {out}");
        assert!(out.contains("deadbee"), "short sha: {out}");
        assert!(out.contains("skade v0.1.4"), "version: {out}");
        assert!(!out.contains("mermaid"), "no mermaid: {out}");
        assert!(out.trim_end().ends_with("</svg>"));
    }

    #[test]
    fn depgraph_svg_labels_edge_via() {
        let out = depgraph_svg(&sample_timeline());
        assert!(out.starts_with("<svg"), "{out}");
        assert!(out.contains(">skade<"), "node skade: {out}");
        assert!(out.contains(">skade-katalog<"), "node skade-katalog: {out}");
        assert!(
            out.contains(">skade-katalog</text>"),
            "edge via label: {out}"
        );
        assert!(out.contains("<line "), "edge line: {out}");
        assert!(!out.contains("mermaid"), "no mermaid: {out}");
    }

    #[test]
    fn snapshot_edge_list_is_sorted_markdown() {
        let out = snapshot_edge_list(&sample_timeline());
        assert!(
            out.contains("- `skade` → `skade-katalog` (via skade-katalog)"),
            "{out}"
        );
    }

    #[test]
    fn gate_matrix_marks_success() {
        let out = gate_matrix_table(&sample_timeline());
        assert!(out.contains("| Release |"), "{out}");
        assert!(out.contains("skade"), "{out}");
        assert!(out.contains('✓'), "succeeded → check: {out}");
    }

    #[test]
    fn release_versions_lists_published() {
        let out = release_versions_table(&sample_timeline());
        assert!(out.contains("skade v0.1.4"), "{out}");
        assert!(out.contains("2026-06-10"), "{out}");
    }

    #[test]
    fn bench_history_table_newest_first_with_thousands() {
        let out = bench_history_table(&sample_timeline(), Some(20));
        assert!(out.contains("skade_table_exists_ops_sec"), "{out}");
        assert!(out.contains("2,000,000"), "thousands sep: {out}");
    }

    #[test]
    fn bench_compare_table_one_row_per_repo() {
        let out = bench_compare_table(&sample_timeline());
        assert!(out.contains("| skade |"), "{out}");
        assert!(out.contains("2,000,000"), "{out}");
    }

    #[test]
    fn lane_summary_shows_latest() {
        let out = lane_summary_table(&sample_timeline());
        assert!(out.contains("| skade |"), "{out}");
        assert!(out.contains("deadbee"), "{out}");
        assert!(out.contains("succeeded"), "{out}");
    }

    #[test]
    fn empty_timeline_placeholders() {
        let tl = Timeline {
            workspace_name: "empty".into(),
            lanes: vec![],
            release_order: vec![],
            release_snapshot: BTreeMap::new(),
            snapshots: BTreeMap::new(),
            latest_snapshot: None,
            bench_history: BTreeMap::new(),
        };
        assert!(timeline_svg(&tl).contains("no releases"));
        assert!(depgraph_svg(&tl).contains("no dependency-graph snapshot"));
        assert!(bench_history_table(&tl, None).contains("no benchmark history"));
        assert!(gate_matrix_table(&tl).contains("no releases"));
    }
}