Skip to main content

cu_profiler_report/
markdown.rs

1//! Markdown output, intended for GitHub PR comments.
2
3use crate::model::{Report, scenario_budget, scenario_delta_pct, thousands};
4
5/// Render `report` as a Markdown document.
6#[must_use]
7pub fn render(report: &Report) -> String {
8    let mut out = String::new();
9    out.push_str("## cu-profiler report\n\n");
10
11    let sum = &report.summary;
12    out.push_str(&format!(
13        "**{}** scenario(s): {} passed · {} warned · {} failed — **{} total CU**\n\n",
14        sum.total_scenarios,
15        sum.passed,
16        sum.warned,
17        sum.failed,
18        thousands(sum.total_cu),
19    ));
20
21    out.push_str("| Scenario | Actual CU | Budget | Delta | Status |\n");
22    out.push_str("| --- | ---: | ---: | ---: | :---: |\n");
23    for s in &report.scenarios {
24        let budget = scenario_budget(s).map_or_else(|| "—".to_string(), thousands);
25        let delta = scenario_delta_pct(s).map_or_else(|| "—".to_string(), |d| format!("{d:+.1}%"));
26        out.push_str(&format!(
27            "| `{}` | {} | {} | {} | {} {} |\n",
28            md_code(&s.name),
29            thousands(s.measurement.total_cu),
30            budget,
31            delta,
32            status_emoji(s.status),
33            s.status.label(),
34        ));
35    }
36
37    let diagnostics: Vec<_> = report
38        .scenarios
39        .iter()
40        .flat_map(|s| &s.diagnostics)
41        .collect();
42    if !diagnostics.is_empty() {
43        out.push_str("\n### Diagnostics\n\n");
44        for d in diagnostics {
45            out.push_str(&format!(
46                "- **{}** (`{}`)\n  - {}\n  - _Recommendation:_ {}\n",
47                md_text(&d.title),
48                md_code(&d.scenario),
49                md_text(&d.evidence),
50                md_text(&d.recommendation),
51            ));
52        }
53    }
54
55    out
56}
57
58/// Sanitise a value for a Markdown table cell or inline text: collapse newlines
59/// and escape the pipe that would otherwise split the row.
60fn md_text(s: &str) -> String {
61    s.replace(['\n', '\r'], " ").replace('|', "\\|")
62}
63
64/// Sanitise a value placed inside an inline code span (`` `…` ``): in addition to
65/// [`md_text`], neutralise backticks that would close the span early.
66fn md_code(s: &str) -> String {
67    md_text(s).replace('`', "'")
68}
69
70fn status_emoji(status: cu_profiler_core::model::Status) -> &'static str {
71    use cu_profiler_core::model::Status;
72    match status {
73        Status::Pass => "🟢",
74        Status::Warn => "🟡",
75        Status::Fail => "🔴",
76        Status::Unknown => "⚪",
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use cu_profiler_core::Profiler;
84    use cu_profiler_core::backend::RecordedLogsBackend;
85    use cu_profiler_core::metadata::RunMetadata;
86    use cu_profiler_core::scenario::Scenario;
87
88    #[test]
89    fn renders_markdown_table() {
90        let mut backend = RecordedLogsBackend::new();
91        backend.insert_blob(
92            "swap",
93            "Program P invoke [1]\nProgram P consumed 1000 of 200000 compute units\nProgram P success",
94            true,
95        );
96        let report = Profiler::new().run(
97            &backend,
98            &[Scenario::new("swap")],
99            None,
100            RunMetadata::recorded("0.1.0"),
101        );
102        let md = render(&report);
103        assert!(md.contains("## cu-profiler report"));
104        assert!(md.contains("| `swap` |"));
105        assert!(md.contains("PASS"));
106    }
107
108    #[test]
109    fn sanitises_pipes_backticks_and_newlines() {
110        assert_eq!(md_text("a|b"), "a\\|b");
111        assert_eq!(md_text("line1\nline2"), "line1 line2");
112        assert_eq!(md_code("we`ird|name"), "we'ird\\|name");
113    }
114
115    #[test]
116    fn malicious_scenario_name_does_not_break_table_row() {
117        let mut backend = RecordedLogsBackend::new();
118        backend.insert_blob(
119            "evil|name`",
120            "Program P invoke [1]\nProgram P consumed 1000 of 200000 compute units\nProgram P success",
121            true,
122        );
123        let report = Profiler::new().run(
124            &backend,
125            &[Scenario::new("evil|name`")],
126            None,
127            RunMetadata::recorded("0.1.0"),
128        );
129        let md = render(&report);
130        let row = md
131            .lines()
132            .find(|l| l.contains("evil"))
133            .expect("data row present");
134        // A 5-column row has 6 structural `|`. The pipe inside the name must be
135        // escaped (`\|`), so structural = total pipes − escaped pipes = 6.
136        let total_pipes = row.matches('|').count();
137        let escaped_pipes = row.matches("\\|").count();
138        assert_eq!(
139            total_pipes - escaped_pipes,
140            6,
141            "unescaped pipe leaked into row: {row}"
142        );
143        assert!(row.contains("evil\\|name'"), "name not sanitised: {row}");
144    }
145}