1use crate::report::{DocumentKey, ReportResult};
2use std::{
3 fmt::{Display, Formatter},
4 path::Path,
5};
6use url::Url;
7use walker_common::{locale::Formatted, report, report::Summary};
8
9#[derive(Clone, Debug)]
10pub struct ReportRenderOption<'a> {
11 pub output: &'a Path,
12
13 pub base_url: &'a Option<Url>,
14 pub source_url: &'a Option<Url>,
15}
16
17pub fn render_to_html<W: std::io::Write>(
18 out: &mut W,
19 report: &ReportResult,
20 options: ReportRenderOption,
21) -> anyhow::Result<()> {
22 report::render(
23 out,
24 "CSAF Report",
25 HtmlReport {
26 result: report,
27 base_url: options.base_url,
28 source_url: options.source_url,
29 },
30 &Default::default(),
31 )?;
32
33 Ok(())
34}
35
36#[derive(Copy, Clone, PartialEq, Eq, Debug)]
37pub enum Title {
38 Duplicates,
39 Warnings,
40 Errors,
41}
42
43impl Display for Title {
44 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
45 match self {
46 Self::Duplicates => f.write_str("Duplicates"),
47 Self::Warnings => f.write_str("Warnings"),
48 Self::Errors => f.write_str("Errors"),
49 }
50 }
51}
52
53struct HtmlReport<'r> {
54 result: &'r ReportResult<'r>,
55 base_url: &'r Option<Url>,
57 source_url: &'r Option<Url>,
59}
60
61impl HtmlReport<'_> {
62 fn render_duplicates(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
63 let count = self.result.duplicates.duplicates.len();
64 let data = |f: &mut Formatter<'_>| {
65 for (k, v) in &self.result.duplicates.duplicates {
66 let (_url, label) = self.link_document(k);
67 writeln!(
68 f,
69 r#"
70 <tr>
71 <td><code>{label}<code></td>
72 <td class="text-right">{v}</td>
73 </tr>
74 "#,
75 label = html_escape::encode_text(&label),
76 )?;
77 }
78 Ok(())
79 };
80
81 if !self.result.duplicates.duplicates.is_empty() {
82 let total: usize = self.result.duplicates.duplicates.values().sum();
83
84 Self::render_table(
85 f,
86 [count],
87 Title::Duplicates,
88 format!(
89 "{count} duplicates URLs found, totaling {total} redundant entries",
90 count = Formatted(count),
91 total = Formatted(total),
92 )
93 .as_str(),
94 data,
95 )?;
96 }
97 Ok(())
98 }
99
100 fn render_errors(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
101 let count = self.result.errors.len();
102
103 let data = |f: &mut Formatter<'_>| {
104 for (k, v) in self.result.errors {
105 let (url, label) = self.link_document(k);
106
107 let id = format!("error-{url}");
108 let id = html_escape::encode_quoted_attribute(&id);
109
110 writeln!(
111 f,
112 r##"
113 <tr>
114 <td id="{id}"><a href="{url}" target="_blank" style="white-space: nowrap;">{label}</a> <a class="link-secondary" href="#{id}">§</a></td>
115 <td><code>{v}</code></td>
116 </tr>
117 "##,
118 url = html_escape::encode_quoted_attribute(&url),
119 label = html_escape::encode_text(&label),
120 v = html_escape::encode_text(&v),
121 )?;
122 }
123 Ok(())
124 };
125 if count > 0 {
126 Self::render_table(
127 f,
128 [count],
129 Title::Errors,
130 &format!("{count} file(s) with errors", count = Formatted(count),),
131 data,
132 )?;
133 }
134 Ok(())
135 }
136
137 fn render_table<F>(
138 f: &mut Formatter<'_>,
139 count: impl IntoIterator<Item = usize>,
140 title: Title,
141 sub_title: &str,
142 data: F,
143 ) -> std::fmt::Result
144 where
145 F: Fn(&mut Formatter<'_>) -> std::fmt::Result,
146 {
147 Self::title(f, title, count)?;
148 writeln!(f, "<p>{sub_title}</p>")?;
149
150 writeln!(
151 f,
152 r#"
153 <table class="table">
154 <thead>
155 <tr>
156 <th scope="col">File</th>
157 <th scope="col">{title}</th>
158 </tr>
159 </thead>
160
161 <tbody>
162"#
163 )?;
164 data(f)?;
165 writeln!(f, "</tbody></table>")?;
166
167 Ok(())
168 }
169
170 fn render_warnings(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
171 let file_count = self.result.warnings.len();
172 let total_count = self.result.warnings.values().map(|w| w.len()).sum();
173
174 let data = |f: &mut Formatter<'_>| {
175 for (k, v) in self.result.warnings {
176 let (url, label) = self.link_document(k);
177
178 let id = format!("warning-{url}");
179 let id = html_escape::encode_quoted_attribute(&id);
180
181 writeln!(
182 f,
183 r##"
184 <tr>
185 <td id="{id}"><a href="{url}" target="_blank" style="white-space: nowrap;">{label}</a> <a class="link-secondary" href="#{id}">§</a></td>
186 <td><ul>
187"##,
188 url = html_escape::encode_quoted_attribute(&url),
189 label = html_escape::encode_text(&label),
190 )?;
191
192 for text in v {
193 writeln!(
194 f,
195 r#"
196 <li>
197 <code>{v}</code>
198 </li>
199 "#,
200 v = html_escape::encode_text(&text),
201 )?;
202 }
203
204 writeln!(
205 f,
206 r#"
207 </ul>
208 </td>
209 </tr>
210"#
211 )?;
212 }
213
214 Ok(())
215 };
216 if total_count > 0 {
217 Self::render_table(
218 f,
219 [file_count, total_count],
220 Title::Warnings,
221 &format!(
222 "{total_count} warning(s) in {file_count} file(s) detected",
223 total_count = Formatted(total_count),
224 file_count = Formatted(file_count),
225 ),
226 data,
227 )?;
228 }
229 Ok(())
230 }
231
232 fn gen_link(&self, key: &DocumentKey) -> Option<(String, String)> {
233 let label = key.url.clone();
234
235 let url = key.distribution_url.join(&key.url).ok()?;
237
238 let url = match &self.base_url {
239 Some(base_url) => base_url
240 .make_relative(&url)
241 .unwrap_or_else(|| url.to_string()),
242 None => url.to_string(),
243 };
244
245 Some((url, label))
246 }
247
248 fn link_document(&self, key: &DocumentKey) -> (String, String) {
250 self.gen_link(key)
251 .unwrap_or_else(|| (key.url.clone(), key.url.clone()))
252 }
253
254 fn title(
255 f: &mut Formatter<'_>,
256 title: Title,
257 count: impl IntoIterator<Item = usize>,
258 ) -> std::fmt::Result {
259 write!(f, "<h2>{title}")?;
260
261 for count in count {
262 let (class, text) = if count > 0 {
263 (
264 match title {
265 Title::Warnings => "text-bg-warning",
266 _ => "text-bg-danger",
267 },
268 Formatted(count).to_string(),
269 )
270 } else {
271 ("text-bg-light", "None".to_string())
272 };
273
274 write!(
275 f,
276 r#" <span class="badge {class} rounded-pill">{text}</span>"#,
277 )?;
278 }
279
280 writeln!(f, "</h2>")?;
281
282 Ok(())
283 }
284
285 fn render_total(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
286 let mut summary = Vec::new();
287
288 summary.push(("Total", Formatted(self.result.total).to_string()));
289 if let Some(source) = self.source_url.as_ref().or(self.base_url.as_ref()) {
290 summary.push(("Source", source.to_string()));
291 }
292
293 Summary(summary).fmt(f)
294 }
295}
296
297impl Display for HtmlReport<'_> {
298 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
299 self.render_total(f)?;
300 self.render_duplicates(f)?;
301 self.render_errors(f)?;
302 self.render_warnings(f)?;
303 Ok(())
304 }
305}
306
307#[cfg(test)]
308mod test {
309 use super::*;
310 use reqwest::Url;
311 use std::path::PathBuf;
312
313 #[test]
314 fn test_link() {
315 let details = ReportResult {
316 total: 0,
317 duplicates: &Default::default(),
318 errors: &Default::default(),
319 warnings: &Default::default(),
320 };
321 let _output = PathBuf::default();
322 let base_url = Some(Url::parse("file:///foo/bar/").expect("example value must parse"));
323 let report = HtmlReport {
324 result: &details,
325 base_url: &base_url,
326 source_url: &None,
327 };
328
329 let (url, _label) = report.link_document(&DocumentKey {
330 distribution_url: Url::parse("file:///foo/bar/distribution/")
331 .expect("example value must parse"),
332 url: "2023/cve.json".to_string(),
333 });
334
335 assert_eq!(url, "distribution/2023/cve.json");
336 }
337}