Skip to main content

semdiff_output/
html.rs

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}