nornir 0.5.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! **Coverage-metrics grid SVG (S6 step 5)** — the cross-workspace completeness
//! dashboard as a self-contained static SVG.
//!
//! Where [`crate::autonom::megagate`] computes the verdict and
//! [`crate::viz::metro_feed`] draws the per-button metro lines, THIS renders the
//! whole served dimension at a glance: one ROW per workspace, three metric CELLS
//! per row — **ran** (the completeness gate: surface covered/total), **utfallsrum**
//! (outcome-space classes swept), **reachable** (the resolved metro / LAW-9 walk).
//! Each cell is GREEN when its metric is fully met, RED when it falls short, and a
//! neutral NA when the metric wasn't measured for that workspace.
//!
//! House style mirrors [`crate::viz::diagram`] / `docs::svg` / the `dep_graph_svg`
//! MCP tool: a hand-emitted, self-contained SVG — no JavaScript, no diagram
//! engine, no egui — so it renders verbatim in the Codeberg/GitHub web UI, a
//! markdown viewer, or the docs PDF. Every cell carries a `class` (`green`/`red`/
//! `na`) so the headless matrix can assert the grid as DATA (LAW 6).

use std::fmt::Write as _;

use crate::autonom::megagate::{MegaGateReport, WorkspaceGate};

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

/// The three metric columns, in render order.
const COLUMNS: &[&str] = &["ran", "utfallsrum", "reachable"];

/// One rendered cell: its display text + its class (`green`/`red`/`na`).
struct Cell {
    text: String,
    class: &'static str,
}

/// A `(met, total)` rollup → a cell: GREEN iff `met == total` (and measured),
/// else RED, rendered `met/total`.
fn ratio_cell(rollup: Option<(usize, usize)>) -> Cell {
    match rollup {
        Some((met, total)) => Cell {
            text: format!("{met}/{total}"),
            class: if met == total { "green" } else { "red" },
        },
        None => Cell { text: "".into(), class: "na" },
    }
}

/// The `ran` (completeness) cell for a workspace: covered/total surface nodes,
/// GREEN iff the gate is green (gap empty AND no stale allowlist entry).
fn ran_cell(wg: &WorkspaceGate) -> Cell {
    let g = &wg.coverage.gap;
    Cell {
        text: format!("{}/{}", g.covered + g.allowlisted.len(), g.total),
        class: if wg.coverage.is_green() { "green" } else { "red" },
    }
}

/// The three cells for one workspace, in [`COLUMNS`] order.
fn cells_for(wg: &WorkspaceGate) -> Vec<Cell> {
    vec![
        ran_cell(wg),
        ratio_cell(wg.utfallsrum),
        ratio_cell(wg.reachable),
    ]
}

/// Render `report` to a self-contained grid SVG string: a header row of
/// [`COLUMNS`] and one row per workspace with its three metric cells. Each cell
/// `<rect>` carries `class="cell green|red|na"` and the value text; each row also
/// carries the workspace name. Pure (no egui / no warehouse) — feed a report, get
/// the SVG, assert the cells.
pub fn render_metrics_svg(report: &MegaGateReport) -> String {
    let label_w = 130.0f64;
    let col_w = 120.0f64;
    let row_h = 30.0f64;
    let header_h = 28.0f64;
    let margin = 14.0f64;
    let title_h = 26.0f64;

    let ncols = COLUMNS.len();
    let nrows = report.workspaces.len();
    let width = margin * 2.0 + label_w + col_w * ncols as f64;
    let height = margin * 2.0 + title_h + header_h + row_h * nrows.max(1) 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"
    );
    // Style: the green/red/na cell classes (the assertable verdict surface).
    s.push_str(
        "<style>\
         .cell.green{fill:#e6f3ea;stroke:#3c8a52;}\
         .cell.red{fill:#f7e6e6;stroke:#b3434a;}\
         .cell.na{fill:#eeeeee;stroke:#9a9aa2;}\
         </style>\n",
    );
    // Title.
    let _ = write!(
        s,
        "<text x=\"{:.0}\" y=\"{:.0}\" font-size=\"13\" font-weight=\"bold\">coverage metrics — {}</text>\n",
        margin,
        margin + 14.0,
        xml_escape(&report.summary()),
    );

    // Header row (column labels).
    let header_y = margin + title_h;
    for (ci, col) in COLUMNS.iter().enumerate() {
        let x = margin + label_w + ci as f64 * col_w;
        let _ = write!(
            s,
            "<text x=\"{:.0}\" y=\"{:.0}\" font-weight=\"bold\">{}</text>\n",
            x + col_w / 2.0 - 4.0,
            header_y + header_h / 2.0 + 4.0,
            xml_escape(col),
        );
    }

    // One row per workspace.
    for (ri, wg) in report.workspaces.iter().enumerate() {
        let row_y = header_y + header_h + ri as f64 * row_h;
        let _ = write!(
            s,
            "<text x=\"{:.0}\" y=\"{:.0}\" font-weight=\"bold\">{}</text>\n",
            margin,
            row_y + row_h / 2.0 + 4.0,
            xml_escape(&wg.workspace),
        );
        for (ci, cell) in cells_for(wg).into_iter().enumerate() {
            let x = margin + label_w + ci as f64 * col_w;
            let _ = write!(
                s,
                "<rect class=\"cell {}\" x=\"{x:.0}\" y=\"{row_y:.0}\" width=\"{:.0}\" height=\"{:.0}\" \
                 rx=\"3\" stroke-width=\"0.8\"/>\n",
                cell.class,
                col_w - 8.0,
                row_h - 6.0,
            );
            let _ = write!(
                s,
                "<text x=\"{:.0}\" y=\"{:.0}\">{}</text>\n",
                x + 8.0,
                row_y + row_h / 2.0 + 3.0,
                xml_escape(&cell.text),
            );
        }
    }

    s.push_str("</svg>\n");
    s
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::autonom::megagate::WorkspaceGate;
    use nornir_testmatrix::coverage::GateReport;
    use nornir_testmatrix::discover::{cli_commands, Surface, SurfaceNode};
    use std::collections::BTreeSet;

    fn surface_ab() -> Surface {
        let mut s = Surface::new();
        s.extend(cli_commands(["a", "b"]));
        s
    }

    fn green_report(ws: &str) -> GateReport {
        let surface = surface_ab();
        let covered: BTreeSet<String> = surface.nodes.iter().map(SurfaceNode::key_str).collect();
        GateReport::compute("r", ws, &surface, &covered, &Default::default())
    }

    fn red_report(ws: &str) -> GateReport {
        let surface = surface_ab();
        let covered: BTreeSet<String> = ["cli_command:a@na".to_string()].into_iter().collect();
        GateReport::compute("r", ws, &surface, &covered, &Default::default())
    }

    /// Inject a known two-workspace report and assert the rendered grid: a row per
    /// workspace, three cells each, with the exact green/red/na classes + values.
    #[test]
    fn renders_per_workspace_rows_and_cells_with_classes() {
        let report = MegaGateReport {
            run_id: "r".into(),
            workspaces: vec![
                // alpha: gate green (2/2 covered), utfallsrum met 2/2, reachable 1/1.
                WorkspaceGate::from_report("alpha", green_report("alpha"))
                    .with_utfallsrum(2, 2)
                    .with_reachable(1, 1),
                // beta: gate red (1/2, b uncovered), no utfallsrum, reachable 1/2 (red).
                WorkspaceGate::from_report("beta", red_report("beta")).with_reachable(1, 2),
            ],
        };
        let svg = render_metrics_svg(&report);

        assert!(svg.starts_with("<svg"), "an svg root: {}", &svg[..40.min(svg.len())]);
        assert!(svg.ends_with("</svg>\n"));
        // Both workspace rows are present.
        assert!(svg.contains(">alpha<"), "alpha row label");
        assert!(svg.contains(">beta<"), "beta row label");
        // The three column headers.
        for col in COLUMNS {
            assert!(svg.contains(&format!(">{col}<")), "header `{col}` present");
        }

        // alpha: ran green (2/2), utfallsrum green (2/2), reachable green (1/1).
        let alpha_greens = svg.matches("class=\"cell green\"").count();
        let reds = svg.matches("class=\"cell red\"").count();
        let nas = svg.matches("class=\"cell na\"").count();
        // 3 alpha cells green + 0 from beta's ran(red)/reachable(red); beta has 1
        // na (utfallsrum) and 2 red (ran + reachable).
        assert_eq!(alpha_greens, 3, "alpha's three cells are green");
        assert_eq!(reds, 2, "beta's ran + reachable cells are red");
        assert_eq!(nas, 1, "beta's unmeasured utfallsrum is na");

        // Values: alpha ran shows 2/2, beta ran shows 1/2, beta reachable 1/2.
        assert!(svg.contains(">2/2<"), "alpha covered/total");
        assert!(svg.contains(">1/2<"), "beta covered/total + reachable shortfall");
        assert!(svg.contains(">—<"), "beta utfallsrum is the NA dash");
    }

    /// A report with NO measured rollups renders every secondary cell as NA, and
    /// the ran cell reflects the gate verdict.
    #[test]
    fn unmeasured_rollups_render_na() {
        let report = MegaGateReport {
            run_id: "r".into(),
            workspaces: vec![WorkspaceGate::from_report("solo", green_report("solo"))],
        };
        let svg = render_metrics_svg(&report);
        // ran green; utfallsrum + reachable both na → 2 na cells, 1 green, 0 red.
        assert_eq!(svg.matches("class=\"cell green\"").count(), 1);
        assert_eq!(svg.matches("class=\"cell na\"").count(), 2);
        assert_eq!(svg.matches("class=\"cell red\"").count(), 0);
    }
}