Skip to main content

cuke_dedup/reporters/
html.rs

1use super::shared::{
2    bounded_findings, count_label, report_safe, report_value_with_metadata,
3    MAX_REPORTED_CLUSTER_MEMBERS,
4};
5use super::{CliReportMetadata, ReportContext};
6use crate::model::{DefinitionComparison, MatcherDiff};
7use anyhow::{Context, Result};
8
9const HTML_TEMPLATE: &str = include_str!("templates/report.html");
10
11/// Renders the self-contained interactive HTML report without writing it to disk.
12pub fn render_html(context: &ReportContext<'_>) -> Result<String> {
13    render_html_context_with_metadata(context, None)
14}
15
16pub(super) fn render_html_context_with_metadata(
17    context: &ReportContext<'_>,
18    metadata: Option<&CliReportMetadata<'_>>,
19) -> Result<String> {
20    let result = context.result;
21    let root = context.root;
22    let summary = &context.summary;
23    let rule_summary = if summary.by_rule.is_empty() {
24        String::new()
25    } else {
26        let counts = summary
27            .by_rule
28            .iter()
29            .map(|(rule, count)| {
30                format!(
31                    r#"<li><code>{}</code><strong>{count}</strong></li>"#,
32                    html_escape(rule)
33                )
34            })
35            .collect::<String>();
36        format!(r#"<ul class="rule-summary" aria-label="Findings by rule">{counts}</ul>"#)
37    };
38    let contributing_rules = if summary.duplication.rules.is_empty() {
39        String::new()
40    } else {
41        format!(
42            r#"<p class="threshold-rules">Contributing rules: {}</p>"#,
43            summary
44                .duplication
45                .rules
46                .iter()
47                .map(|rule| format!("<code>{}</code>", html_escape(rule.as_str())))
48                .collect::<Vec<_>>()
49                .join(", ")
50        )
51    };
52    let report_json = json_for_html(
53        &serde_json::to_string(&report_value_with_metadata(context, metadata))
54            .context("failed to serialize HTML report data")?,
55    );
56    let mut cards = String::new();
57    let selected = bounded_findings(result, true);
58    let truncated = selected.truncated;
59    for finding in selected.findings {
60        let location = finding.primary.display(root);
61        let retained_related = MAX_REPORTED_CLUSTER_MEMBERS.saturating_sub(1);
62        let mut related_locations = finding
63            .related
64            .iter()
65            .take(retained_related)
66            .map(|related| {
67                format!(
68                    r#"<li><code>{}</code></li>"#,
69                    html_escape(&related.display(root))
70                )
71            })
72            .collect::<String>();
73        if finding.related.len() > retained_related {
74            related_locations.push_str(&format!(
75                "<li>... {} more locations omitted</li>",
76                finding.related.len() - retained_related
77            ));
78        }
79        let related = if related_locations.is_empty() {
80            String::new()
81        } else {
82            format!(r#"<ul class="related">{related_locations}</ul>"#)
83        };
84        let comparison = finding
85            .evidence
86            .comparison
87            .as_ref()
88            .map(render_comparison)
89            .unwrap_or_default();
90        let handler_evidence = if finding.evidence.handler_evidence.is_empty() {
91            String::new()
92        } else {
93            format!(
94                r#"<p><strong>Handler evidence:</strong> {}</p>"#,
95                html_escape(&finding.evidence.handler_evidence)
96            )
97        };
98        cards.push_str(&format!(
99            r#"<article class="finding" data-rule="{}" data-severity="{}"><header><span class="severity {}">{}</span><code>{}</code></header><h2>{}</h2><p class="location">{}</p>{related}<p>{}</p>{handler_evidence}{comparison}<p><strong>Action:</strong> {}</p></article>"#,
100            finding.rule,
101            finding.severity,
102            finding.severity,
103            finding.severity,
104            finding.rule,
105            html_escape(&finding.message),
106            html_escape(&location),
107            html_escape(&finding.evidence.matcher_difference),
108            html_escape(&finding.suggested_action),
109        ));
110    }
111    if cards.is_empty() {
112        cards.push_str("<p class=empty>No active findings.</p>");
113    }
114    let mut truncation_notice = String::new();
115    if let Some(metadata) = metadata.filter(|metadata| metadata.analysis.truncated) {
116        let analysis = metadata.analysis;
117        truncation_notice.push_str(&format!(
118            r#"<p class="truncation-notice" role="alert">{}</p>"#,
119            html_escape(&format!(
120                "Analysis is incomplete: {} candidate comparisons were evaluated and {} were skipped after configured limits.",
121                analysis.candidate_comparisons_evaluated,
122                analysis.skipped_candidate_comparisons
123            ))
124        ));
125    }
126    if metadata.is_some_and(|metadata| metadata.corpus.incomplete) {
127        truncation_notice.push_str(&format!(
128            r#"<p class="truncation-notice" role="alert">{}</p>"#,
129            html_escape(
130                "Corpus is incomplete: one or more definition or feature inputs could not be represented fully, so some analysis was disabled or omitted."
131            )
132        ));
133    }
134    if truncated > 0 {
135        truncation_notice.push_str(&format!(
136            r#"<p class="truncation-notice" role="status">{}</p>"#,
137            html_escape(&format!(
138                "Showing the highest-severity active findings; {truncated} additional findings are available in the summary and streaming JSONL report."
139            ))
140        ));
141    }
142
143    let replacements = [
144        (
145            "{{DEFINITIONS}}",
146            count_label(summary.definitions_analyzed, "definition"),
147        ),
148        (
149            "{{FEATURE_STEPS}}",
150            count_label(summary.feature_steps_analyzed, "feature step"),
151        ),
152        ("{{ERRORS}}", count_label(summary.errors, "error")),
153        ("{{WARNINGS}}", count_label(summary.warnings, "warning")),
154        ("{{SUPPRESSED}}", summary.suppressed.to_string()),
155        (
156            "{{THRESHOLD_CLASS}}",
157            if summary.duplication.passed {
158                "pass".to_owned()
159            } else {
160                "fail".to_owned()
161            },
162        ),
163        (
164            "{{DUPLICATED_DEFINITIONS}}",
165            summary.duplication.duplicated_definitions.to_string(),
166        ),
167        (
168            "{{TOTAL_DEFINITIONS}}",
169            summary.duplication.total_definitions.to_string(),
170        ),
171        (
172            "{{DUPLICATION_PERCENTAGE}}",
173            format!("{:.2}", summary.duplication.percentage),
174        ),
175        (
176            "{{DUPLICATION_THRESHOLD}}",
177            format!("{:.2}", summary.duplication.threshold),
178        ),
179        (
180            "{{THRESHOLD_STATUS}}",
181            if summary.duplication.passed {
182                "PASS".to_owned()
183            } else {
184                "FAIL".to_owned()
185            },
186        ),
187        ("{{RULE_SUMMARY}}", rule_summary),
188        ("{{CONTRIBUTING_RULES}}", contributing_rules),
189        ("{{TRUNCATION_NOTICE}}", truncation_notice),
190        ("{{CARDS}}", cards),
191        ("{{REPORT_JSON}}", report_json),
192    ];
193    Ok(render_template(
194        HTML_TEMPLATE.strip_suffix('\n').unwrap_or(HTML_TEMPLATE),
195        &replacements,
196    ))
197}
198
199fn render_template(template: &str, replacements: &[(&str, String)]) -> String {
200    let mut rendered = String::with_capacity(template.len());
201    let mut remaining = template;
202    while let Some((index, placeholder, value)) = replacements
203        .iter()
204        .filter_map(|(placeholder, value)| {
205            remaining
206                .find(placeholder)
207                .map(|index| (index, *placeholder, value))
208        })
209        .min_by_key(|(index, _, _)| *index)
210    {
211        rendered.push_str(&remaining[..index]);
212        rendered.push_str(value);
213        remaining = &remaining[index + placeholder.len()..];
214    }
215    rendered.push_str(remaining);
216    rendered
217}
218
219fn render_comparison(comparison: &DefinitionComparison) -> String {
220    format!(
221        r#"<div class="comparison"><section><h3>Primary definition</h3><code class="matcher">{}</code><pre><code>{}</code></pre></section><section><h3>Related definition</h3><code class="matcher">{}</code><pre><code>{}</code></pre></section></div>"#,
222        render_matcher_side(&comparison.matcher_diff, true),
223        html_escape(&comparison.left_handler),
224        render_matcher_side(&comparison.matcher_diff, false),
225        html_escape(&comparison.right_handler),
226    )
227}
228
229fn render_matcher_side(diff: &MatcherDiff, left: bool) -> String {
230    let change = if left {
231        &diff.left_change
232    } else {
233        &diff.right_change
234    };
235    let change = if change.is_empty() {
236        String::new()
237    } else {
238        format!("<mark>{}</mark>", html_escape(change))
239    };
240    format!(
241        "{}{}{}",
242        html_escape(&diff.prefix),
243        change,
244        html_escape(&diff.suffix)
245    )
246}
247
248fn html_escape(value: &str) -> String {
249    report_safe(value)
250        .replace('&', "&amp;")
251        .replace('<', "&lt;")
252        .replace('>', "&gt;")
253        .replace('"', "&quot;")
254        .replace('\'', "&#39;")
255}
256
257fn json_for_html(value: &str) -> String {
258    let mut output = String::with_capacity(value.len());
259    for character in value.chars() {
260        if matches!(
261            character,
262            '<' | '>' | '&'
263                | '\u{061c}'
264                | '\u{200e}'
265                | '\u{200f}'
266                | '\u{202a}'..='\u{202e}'
267                | '\u{2066}'..='\u{2069}'
268        ) {
269            output.push_str(&format!("\\u{:04x}", character as u32));
270        } else {
271            output.push(character);
272        }
273    }
274    output
275}