use std::fmt::Write as _;
use crate::autonom::megagate::{MegaGateReport, WorkspaceGate};
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
const COLUMNS: &[&str] = &["ran", "utfallsrum", "reachable"];
struct Cell {
text: String,
class: &'static str,
}
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" },
}
}
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" },
}
}
fn cells_for(wg: &WorkspaceGate) -> Vec<Cell> {
vec![
ran_cell(wg),
ratio_cell(wg.utfallsrum),
ratio_cell(wg.reachable),
]
}
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"
);
s.push_str(
"<style>\
.cell.green{fill:#e6f3ea;stroke:#3c8a52;}\
.cell.red{fill:#f7e6e6;stroke:#b3434a;}\
.cell.na{fill:#eeeeee;stroke:#9a9aa2;}\
</style>\n",
);
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()),
);
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),
);
}
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())
}
#[test]
fn renders_per_workspace_rows_and_cells_with_classes() {
let report = MegaGateReport {
run_id: "r".into(),
workspaces: vec![
WorkspaceGate::from_report("alpha", green_report("alpha"))
.with_utfallsrum(2, 2)
.with_reachable(1, 1),
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"));
assert!(svg.contains(">alpha<"), "alpha row label");
assert!(svg.contains(">beta<"), "beta row label");
for col in COLUMNS {
assert!(svg.contains(&format!(">{col}<")), "header `{col}` present");
}
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();
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");
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");
}
#[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);
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);
}
}