1use askama::Template;
2use dashmap::DashMap;
3use semdiff_core::Reporter;
4use std::collections::BTreeMap;
5use std::fs;
6use std::fs::File;
7use std::io::{BufWriter, Write};
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10use xxhash_rust::xxh3::xxh3_128;
11
12pub struct HtmlReport {
13 root: PathBuf,
14 detail_dir: PathBuf,
15 detail_dir_name: String,
16 back_link: String,
17 unchanged_entries: DashMap<String, HtmlReportEntry>,
18 modified_entries: DashMap<String, HtmlReportEntry>,
19 added_entries: DashMap<String, HtmlReportEntry>,
20 deleted_entries: DashMap<String, HtmlReportEntry>,
21}
22
23impl HtmlReport {
24 pub fn new(root: PathBuf) -> HtmlReport {
25 let detail_dir_name = root
26 .file_stem()
27 .map(|name| format!("{}_details", name.to_string_lossy()))
28 .unwrap_or_else(|| "details".to_string());
29 let detail_dir = root.parent().unwrap_or_else(|| Path::new(".")).join(&detail_dir_name);
30 let root_file_name = root
31 .file_name()
32 .map(|name| name.to_string_lossy().to_string())
33 .unwrap_or_else(|| "index.html".to_string());
34 let back_link = format!("../{}", root_file_name);
35 HtmlReport {
36 root,
37 detail_dir,
38 detail_dir_name,
39 back_link,
40 unchanged_entries: DashMap::new(),
41 modified_entries: DashMap::new(),
42 added_entries: DashMap::new(),
43 deleted_entries: DashMap::new(),
44 }
45 }
46
47 pub fn record_unchanged(
48 &self,
49 name: &str,
50 compares: &'static str,
51 preview_html: impl Template,
52 detail_html: impl Template,
53 ) -> Result<(), HtmlReportError> {
54 let preview_html = preview_html.render()?;
55 let detail_html = detail_html.render()?;
56 let detail_file_name = Some(self.write_detail(name, HtmlEntryStatus::Unchanged, compares, &detail_html)?);
57 self.insert_entry(
58 HtmlEntryStatus::Unchanged,
59 name,
60 HtmlReportEntry::new(HtmlEntryStatus::Unchanged, compares, preview_html, detail_file_name),
61 );
62 Ok(())
63 }
64
65 pub fn record_modified(
66 &self,
67 name: &str,
68 compares: &'static str,
69 preview_html: impl Template,
70 detail_html: impl Template,
71 ) -> Result<(), HtmlReportError> {
72 let preview_html = preview_html.render()?;
73 let detail_html = detail_html.render()?;
74 let detail_file_name = Some(self.write_detail(name, HtmlEntryStatus::Modified, compares, &detail_html)?);
75 self.insert_entry(
76 HtmlEntryStatus::Modified,
77 name,
78 HtmlReportEntry::new(HtmlEntryStatus::Modified, compares, preview_html, detail_file_name),
79 );
80 Ok(())
81 }
82
83 pub fn record_added(
84 &self,
85 name: &str,
86 compares: &'static str,
87 preview_html: impl Template,
88 detail_html: impl Template,
89 ) -> Result<(), HtmlReportError> {
90 let preview_html = preview_html.render()?;
91 let detail_html = detail_html.render()?;
92 let detail_file_name = Some(self.write_detail(name, HtmlEntryStatus::Added, compares, &detail_html)?);
93 self.insert_entry(
94 HtmlEntryStatus::Added,
95 name,
96 HtmlReportEntry::new(HtmlEntryStatus::Added, compares, preview_html, detail_file_name),
97 );
98 Ok(())
99 }
100
101 pub fn record_deleted(
102 &self,
103 name: &str,
104 compares: &'static str,
105 preview_html: impl Template,
106 detail_html: impl Template,
107 ) -> Result<(), HtmlReportError> {
108 let preview_html = preview_html.render()?;
109 let detail_html = detail_html.render()?;
110 let detail_file_name = Some(self.write_detail(name, HtmlEntryStatus::Deleted, compares, &detail_html)?);
111 self.insert_entry(
112 HtmlEntryStatus::Deleted,
113 name,
114 HtmlReportEntry::new(HtmlEntryStatus::Deleted, compares, preview_html, detail_file_name),
115 );
116 Ok(())
117 }
118
119 fn insert_entry(&self, status: HtmlEntryStatus, name: &str, entry: HtmlReportEntry) {
120 let key = name.to_owned();
121 let previous = match status {
122 HtmlEntryStatus::Unchanged => self.unchanged_entries.insert(key, entry),
123 HtmlEntryStatus::Modified => self.modified_entries.insert(key, entry),
124 HtmlEntryStatus::Added => self.added_entries.insert(key, entry),
125 HtmlEntryStatus::Deleted => self.deleted_entries.insert(key, entry),
126 };
127 assert!(previous.is_none());
128 }
129
130 fn make_detail_filename(name: &str) -> String {
131 format!("{}.html", Self::make_detail_stem(name))
132 }
133
134 fn make_detail_stem(name: &str) -> String {
135 let sanitized = Self::sanitize_segment(name);
136 let hash = xxh3_128(name.as_bytes());
137 format!("{}_{}", sanitized, hash)
138 }
139
140 fn sanitize_segment(value: &str) -> String {
141 value
142 .chars()
143 .map(|ch| {
144 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
145 ch
146 } else {
147 '_'
148 }
149 })
150 .collect()
151 }
152
153 pub fn write_detail_asset(
154 &self,
155 name: &str,
156 label: &str,
157 extension: &str,
158 f: impl FnOnce(&mut BufWriter<File>) -> Result<(), HtmlReportError>,
159 ) -> Result<String, HtmlReportError> {
160 fs::create_dir_all(&self.detail_dir)?;
161 let stem = Self::make_detail_stem(name);
162 let label = Self::sanitize_segment(label);
163 let extension = extension.trim_start_matches('.');
164 let file_name = if extension.is_empty() {
165 format!("{}_{}", stem, label)
166 } else {
167 format!("{}_{}.{}", stem, label, extension)
168 };
169 let file = File::options()
170 .write(true)
171 .create(true)
172 .truncate(true)
173 .open(self.detail_dir.join(&file_name))?;
174 let mut writer = BufWriter::new(file);
175 f(&mut writer)?;
176 writer.flush()?;
177 Ok(file_name)
178 }
179
180 pub fn detail_asset_path(&self, file_name: &str) -> String {
181 format!("{}/{}", self.detail_dir_name, file_name)
182 }
183
184 fn write_detail(
185 &self,
186 name: &str,
187 status: HtmlEntryStatus,
188 compares: &'static str,
189 body_html: &str,
190 ) -> Result<String, HtmlReportError> {
191 fs::create_dir_all(&self.detail_dir)?;
192 let file_name = Self::make_detail_filename(name);
193 let template = DetailTemplate {
194 name,
195 status_label: status.label(),
196 status_class: status.class(),
197 compares,
198 body_html,
199 back_link: &self.back_link,
200 };
201 let rendered = template.render()?;
202 fs::write(self.detail_dir.join(&file_name), rendered)?;
203 Ok(file_name)
204 }
205}
206
207struct HtmlReportEntry {
208 status: HtmlEntryStatus,
209 compares: &'static str,
210 preview_html: String,
211 detail_file_name: Option<String>,
212}
213
214impl HtmlReportEntry {
215 fn new(
216 status: HtmlEntryStatus,
217 compares: &'static str,
218 preview_html: String,
219 detail_file_name: Option<String>,
220 ) -> HtmlReportEntry {
221 HtmlReportEntry {
222 status,
223 compares,
224 preview_html,
225 detail_file_name,
226 }
227 }
228}
229
230#[derive(Clone, Copy, PartialEq, Eq)]
231enum HtmlEntryStatus {
232 Unchanged,
233 Modified,
234 Added,
235 Deleted,
236}
237
238impl HtmlEntryStatus {
239 fn label(self) -> &'static str {
240 match self {
241 HtmlEntryStatus::Unchanged => "unchanged",
242 HtmlEntryStatus::Modified => "modified",
243 HtmlEntryStatus::Added => "added",
244 HtmlEntryStatus::Deleted => "deleted",
245 }
246 }
247
248 fn class(self) -> &'static str {
249 match self {
250 HtmlEntryStatus::Unchanged => "unchanged",
251 HtmlEntryStatus::Modified => "modified",
252 HtmlEntryStatus::Added => "added",
253 HtmlEntryStatus::Deleted => "deleted",
254 }
255 }
256}
257
258#[derive(Debug, Error)]
259pub enum HtmlReportError {
260 #[error("io error: {0}")]
261 Io(#[from] std::io::Error),
262 #[error("template error: {0}")]
263 Template(#[from] askama::Error),
264}
265
266#[derive(Template)]
267#[template(path = "report_root.html")]
268struct RootTemplate<'a> {
269 total: usize,
270 unchanged: usize,
271 modified: usize,
272 added: usize,
273 deleted: usize,
274 entry_groups: &'a [HtmlEntryGroup],
275}
276
277struct HtmlEntryGroup {
278 status_label: &'static str,
279 status_class: &'static str,
280 entries: Vec<HtmlEntryView>,
281}
282
283#[derive(Template)]
284#[template(path = "report_detail.html")]
285struct DetailTemplate<'a> {
286 name: &'a str,
287 status_label: &'a str,
288 status_class: &'a str,
289 compares: &'a str,
290 body_html: &'a str,
291 back_link: &'a str,
292}
293
294struct HtmlEntryView {
295 name: String,
296 status_label: &'static str,
297 status_class: &'static str,
298 compares: &'static str,
299 preview_html: String,
300 detail_link: String,
301}
302
303impl Reporter for HtmlReport {
304 type Error = HtmlReportError;
305
306 fn start(&mut self) -> Result<(), Self::Error> {
307 Ok(())
308 }
309
310 fn finish(self) -> Result<(), Self::Error> {
311 let HtmlReport {
312 root,
313 detail_dir_name,
314 unchanged_entries,
315 modified_entries,
316 added_entries,
317 deleted_entries,
318 ..
319 } = self;
320 let unchanged_count = unchanged_entries.len();
321 let modified_count = modified_entries.len();
322 let added_count = added_entries.len();
323 let deleted_count = deleted_entries.len();
324 let status_order = [
325 (HtmlEntryStatus::Modified, modified_entries),
326 (HtmlEntryStatus::Deleted, deleted_entries),
327 (HtmlEntryStatus::Added, added_entries),
328 (HtmlEntryStatus::Unchanged, unchanged_entries),
329 ];
330 let mut entry_groups = Vec::with_capacity(status_order.len());
331 for (status, entries) in status_order {
332 let sorted_entries = BTreeMap::from_iter(entries);
333 let mut group_entries = Vec::new();
334 for (name, entry) in sorted_entries {
335 let detail_link = entry
336 .detail_file_name
337 .as_ref()
338 .map(|file_name| format!("{}/{}", detail_dir_name, file_name))
339 .unwrap_or_default();
340 group_entries.push(HtmlEntryView {
341 name,
342 status_label: entry.status.label(),
343 status_class: entry.status.class(),
344 compares: entry.compares,
345 preview_html: entry.preview_html.clone(),
346 detail_link,
347 });
348 }
349 entry_groups.push(HtmlEntryGroup {
350 status_label: status.label(),
351 status_class: status.class(),
352 entries: group_entries,
353 });
354 }
355
356 let template = RootTemplate {
357 total: unchanged_count + modified_count + added_count + deleted_count,
358 unchanged: unchanged_count,
359 modified: modified_count,
360 added: added_count,
361 deleted: deleted_count,
362 entry_groups: &entry_groups,
363 };
364 let rendered = template.render()?;
365 fs::write(root, rendered)?;
366 Ok(())
367 }
368}