Skip to main content

cu_profiler_report/
html.rs

1//! Self-contained HTML report.
2//!
3//! Produces a single static HTML document (inline CSS, no assets, no scripts) so
4//! it can be uploaded as a CI artifact or pasted into a PR. It renders the same
5//! data the other formats do — summary, per-scenario measurement, the CPI call
6//! tree, scopes, diagnostics and confidence — purely as presentation.
7
8use std::fmt::Write as _;
9
10use cu_profiler_core::model::{Report, ScenarioReport, Status};
11use cu_profiler_core::parser::CallNode;
12
13use crate::model::{scenario_budget, scenario_delta_pct, thousands};
14
15/// Render `report` as a complete HTML document.
16#[must_use]
17pub fn render(report: &Report) -> String {
18    let mut out = String::new();
19    out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
20    out.push_str("<meta charset=\"utf-8\">\n");
21    out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
22    out.push_str("<title>cu-profiler report</title>\n");
23    out.push_str(STYLE);
24    out.push_str("</head>\n<body>\n");
25    out.push_str("<h1>cu-profiler report</h1>\n");
26
27    let s = &report.summary;
28    let _ = writeln!(
29        out,
30        "<p class=\"summary\">{} scenario(s): \
31         <span class=\"pass\">{} passed</span> · \
32         <span class=\"warn\">{} warned</span> · \
33         <span class=\"fail\">{} failed</span> — <strong>{} total CU</strong></p>",
34        s.total_scenarios,
35        s.passed,
36        s.warned,
37        s.failed,
38        thousands(s.total_cu),
39    );
40
41    push_overview_table(&mut out, report);
42    for scenario in &report.scenarios {
43        push_scenario(&mut out, scenario);
44    }
45
46    out.push_str("</body>\n</html>\n");
47    out
48}
49
50fn push_overview_table(out: &mut String, report: &Report) {
51    out.push_str("<table class=\"overview\">\n<thead><tr>");
52    for h in ["Scenario", "Actual CU", "Budget", "Delta", "Status"] {
53        let _ = write!(out, "<th>{h}</th>");
54    }
55    out.push_str("</tr></thead>\n<tbody>\n");
56    for sc in &report.scenarios {
57        let budget = scenario_budget(sc).map_or_else(|| "—".to_string(), thousands);
58        let delta = scenario_delta_pct(sc).map_or_else(|| "—".to_string(), |d| format!("{d:+.1}%"));
59        let _ = writeln!(
60            out,
61            "<tr><td>{}</td><td class=\"num\">{}</td><td class=\"num\">{}</td>\
62             <td class=\"num\">{}</td><td class=\"{}\">{}</td></tr>",
63            esc(&sc.name),
64            thousands(sc.measurement.total_cu),
65            budget,
66            delta,
67            status_class(sc.status),
68            sc.status.label(),
69        );
70    }
71    out.push_str("</tbody>\n</table>\n");
72}
73
74fn push_scenario(out: &mut String, sc: &ScenarioReport) {
75    let _ = writeln!(
76        out,
77        "<section class=\"scenario\">\n<h2>{} <span class=\"{}\">{}</span></h2>",
78        esc(&sc.name),
79        status_class(sc.status),
80        sc.status.label(),
81    );
82
83    let m = &sc.measurement;
84    let _ = writeln!(
85        out,
86        "<p class=\"meta\">{} CU · {} CPIs · depth {} · confidence {}</p>",
87        thousands(m.total_cu),
88        m.cpi_count,
89        m.cpi_depth,
90        esc(sc.confidence.level.label()),
91    );
92    if !sc.confidence.reasons.is_empty() {
93        out.push_str("<ul class=\"reasons\">\n");
94        for r in &sc.confidence.reasons {
95            let _ = writeln!(out, "<li>{}</li>", esc(r));
96        }
97        out.push_str("</ul>\n");
98    }
99
100    if let Some(tree) = &sc.call_tree {
101        out.push_str("<h3>Call tree</h3>\n");
102        push_tree(out, tree);
103    }
104
105    if !sc.scopes.is_empty() {
106        out.push_str("<h3>Scopes</h3>\n<ul class=\"scopes\">\n");
107        for scope in &sc.scopes {
108            let cu = match (scope.units_estimated, scope.percentage_of_total) {
109                (Some(u), Some(p)) => format!(" — {} CU ({p:.1}%)", thousands(u)),
110                _ => String::new(),
111            };
112            let _ = writeln!(out, "<li>{}{}</li>", esc(&scope.name), esc(&cu));
113        }
114        out.push_str("</ul>\n");
115    }
116
117    if !sc.diagnostics.is_empty() {
118        out.push_str("<h3>Diagnostics</h3>\n<ul class=\"diagnostics\">\n");
119        for d in &sc.diagnostics {
120            let _ = writeln!(
121                out,
122                "<li><strong>{}</strong> — {}<br><em>{}</em></li>",
123                esc(&d.title),
124                esc(&d.evidence),
125                esc(&d.recommendation),
126            );
127        }
128        out.push_str("</ul>\n");
129    }
130
131    out.push_str("</section>\n");
132}
133
134fn push_tree(out: &mut String, node: &CallNode) {
135    out.push_str("<ul class=\"tree\">\n");
136    push_node(out, node);
137    out.push_str("</ul>\n");
138}
139
140fn push_node(out: &mut String, node: &CallNode) {
141    let label = node.label.as_deref().unwrap_or(&node.program_id);
142    let units = node
143        .units_consumed
144        .map_or_else(String::new, |u| format!(" — {} CU", thousands(u)));
145    let _ = write!(out, "<li>{}{}", esc(label), esc(&units));
146    if !node.children.is_empty() {
147        out.push_str("\n<ul>\n");
148        for child in &node.children {
149            push_node(out, child);
150        }
151        out.push_str("</ul>\n");
152    }
153    out.push_str("</li>\n");
154}
155
156fn status_class(status: Status) -> &'static str {
157    match status {
158        Status::Pass => "pass",
159        Status::Warn => "warn",
160        Status::Fail => "fail",
161        Status::Unknown => "unknown",
162    }
163}
164
165/// Escape the five HTML-significant characters.
166fn esc(s: &str) -> String {
167    s.replace('&', "&amp;")
168        .replace('<', "&lt;")
169        .replace('>', "&gt;")
170        .replace('"', "&quot;")
171        .replace('\'', "&#39;")
172}
173
174const STYLE: &str = "<style>\n\
175body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;margin:2rem auto;max-width:60rem;color:#1b1f24;padding:0 1rem}\n\
176h1{font-size:1.5rem}h2{font-size:1.15rem;margin-top:2rem}h3{font-size:1rem;color:#444}\n\
177table{border-collapse:collapse;width:100%;margin:1rem 0}th,td{padding:.4rem .6rem;border-bottom:1px solid #e2e6ea;text-align:left}\n\
178td.num{text-align:right;font-variant-numeric:tabular-nums}\n\
179.pass{color:#1a7f37;font-weight:600}.warn{color:#9a6700;font-weight:600}.fail{color:#cf222e;font-weight:600}.unknown{color:#57606a;font-weight:600}\n\
180.summary{font-size:1.05rem}.meta{color:#57606a}\n\
181ul.tree,ul.tree ul{list-style:none;padding-left:1.1rem;border-left:1px solid #e2e6ea}\n\
182ul.scopes,ul.diagnostics,ul.reasons{padding-left:1.1rem}\n\
183.scenario{border-top:1px solid #e2e6ea;padding-top:.5rem}\n\
184</style>\n";
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use cu_profiler_core::Profiler;
190    use cu_profiler_core::backend::RecordedLogsBackend;
191    use cu_profiler_core::budget::BudgetPolicy;
192    use cu_profiler_core::metadata::RunMetadata;
193    use cu_profiler_core::scenario::Scenario;
194
195    fn report(name: &str) -> Report {
196        let mut backend = RecordedLogsBackend::new();
197        backend.insert_blob(
198            name,
199            "Program User111 invoke [1]\n\
200             Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]\n\
201             Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success\n\
202             Program User111 consumed 96000 of 100000 compute units\n\
203             Program User111 success",
204            true,
205        );
206        let mut scenario = Scenario::new(name);
207        scenario.budget = BudgetPolicy {
208            absolute_max_cu: Some(100_000),
209            warn_at_budget_pct: Some(90.0),
210            ..Default::default()
211        };
212        Profiler::new().run(&backend, &[scenario], None, RunMetadata::recorded("0.1.0"))
213    }
214
215    #[test]
216    fn renders_well_formed_document() {
217        let html = render(&report("swap"));
218        assert!(html.starts_with("<!doctype html>"));
219        assert!(html.contains("<title>cu-profiler report</title>"));
220        assert!(html.contains("cu-profiler report"));
221        assert!(html.contains("swap"));
222        assert!(html.contains("SPL Token")); // labelled CPI in the call tree
223        assert!(html.trim_end().ends_with("</html>"));
224    }
225
226    #[test]
227    fn escapes_html_in_scenario_names() {
228        let html = render(&report("<script>evil</script>"));
229        assert!(!html.contains("<script>evil"));
230        assert!(html.contains("&lt;script&gt;evil&lt;/script&gt;"));
231    }
232}