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
11pub 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('&', "&")
251 .replace('<', "<")
252 .replace('>', ">")
253 .replace('"', """)
254 .replace('\'', "'")
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}