1use crate::build::{BuildOutcome, BuildResult, BuildSummary};
51use crate::db::Database;
52use crate::scan::SkipReason;
53use anyhow::Result;
54use pkgsrc::PkgPath;
55use std::collections::HashMap;
56use std::fs;
57use std::io::Write;
58use std::path::Path;
59
60const BUILD_PHASES: &[(&str, &str)] = &[
62 ("pre-clean", "pre-clean.log"),
63 ("depends", "depends.log"),
64 ("checksum", "checksum.log"),
65 ("configure", "configure.log"),
66 ("build", "build.log"),
67 ("install", "install.log"),
68 ("package", "package.log"),
69 ("deinstall", "deinstall.log"),
70 ("clean", "clean.log"),
71];
72
73struct FailedPackageInfo<'a> {
75 result: &'a BuildResult,
76 breaks_count: usize,
77 failed_phase: Option<String>,
78 failed_log: Option<String>,
79}
80
81fn read_failed_phase(log_dir: &Path) -> Option<String> {
83 let stage_file = log_dir.join(".stage");
84 fs::read_to_string(stage_file).ok().map(|s| s.trim().to_string())
85}
86
87pub fn write_html_report(
92 db: &Database,
93 logdir: &Path,
94 path: &Path,
95) -> Result<()> {
96 let mut results = db.get_all_build_results()?;
97 let breaks_counts = db.count_breaks_for_failed()?;
98 let duration = db.get_total_build_duration()?;
99
100 for (pkgname, pkgpath, reason) in db.get_prefailed_packages()? {
102 results.push(BuildResult {
103 pkgname: pkgsrc::PkgName::new(&pkgname),
104 pkgpath: pkgpath.and_then(|p| pkgsrc::PkgPath::new(&p).ok()),
105 outcome: BuildOutcome::Skipped(SkipReason::PkgFail(reason)),
106 duration: std::time::Duration::ZERO,
107 log_dir: None,
108 });
109 }
110
111 for (pkgname, pkgpath, failed_dep) in db.get_indirect_failures()? {
113 results.push(BuildResult {
114 pkgname: pkgsrc::PkgName::new(&pkgname),
115 pkgpath: pkgpath.and_then(|p| pkgsrc::PkgPath::new(&p).ok()),
116 outcome: BuildOutcome::Skipped(SkipReason::IndirectFail(
117 failed_dep,
118 )),
119 duration: std::time::Duration::ZERO,
120 log_dir: None,
121 });
122 }
123
124 let summary = BuildSummary { duration, results, scanfail: Vec::new() };
125
126 write_report_impl(&summary, &breaks_counts, logdir, path)
127}
128
129fn write_report_impl(
131 summary: &BuildSummary,
132 breaks_counts: &HashMap<String, usize>,
133 logdir: &Path,
134 path: &Path,
135) -> Result<()> {
136 if let Some(parent) = path.parent() {
137 fs::create_dir_all(parent)?;
138 }
139
140 let mut file = fs::File::create(path)?;
141
142 let mut succeeded: Vec<&BuildResult> = summary.succeeded();
144 let mut skipped: Vec<&BuildResult> = summary
145 .results
146 .iter()
147 .filter(|r| {
148 matches!(
149 r.outcome,
150 BuildOutcome::UpToDate | BuildOutcome::Skipped(_)
151 )
152 })
153 .collect();
154
155 let mut failed_info: Vec<FailedPackageInfo> = summary
157 .failed()
158 .into_iter()
159 .map(|result| {
160 let breaks_count = breaks_counts
161 .get(result.pkgname.pkgname())
162 .copied()
163 .unwrap_or(0);
164 let pkg_log_dir = logdir.join(result.pkgname.pkgname());
165 let failed_phase = read_failed_phase(&pkg_log_dir);
166 let failed_log = failed_phase.as_ref().and_then(|phase| {
167 BUILD_PHASES
168 .iter()
169 .find(|(name, _)| *name == phase)
170 .map(|(_, log)| (*log).to_string())
171 });
172 FailedPackageInfo { result, breaks_count, failed_phase, failed_log }
173 })
174 .collect();
175
176 failed_info.sort_by(|a, b| {
178 b.breaks_count.cmp(&a.breaks_count).then_with(|| {
179 a.result.pkgname.pkgname().cmp(b.result.pkgname.pkgname())
180 })
181 });
182
183 succeeded.sort_by(|a, b| a.pkgname.pkgname().cmp(b.pkgname.pkgname()));
184 skipped.sort_by(|a, b| a.pkgname.pkgname().cmp(b.pkgname.pkgname()));
185
186 writeln!(file, "<!DOCTYPE html>")?;
188 writeln!(file, "<html lang=\"en\">")?;
189 writeln!(file, "<head>")?;
190 writeln!(file, " <meta charset=\"UTF-8\">")?;
191 writeln!(
192 file,
193 " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
194 )?;
195 writeln!(file, " <title>pkgsrc Build Report</title>")?;
196 write_styles(&mut file)?;
197 write_sort_script(&mut file)?;
198 writeln!(file, "</head>")?;
199 writeln!(file, "<body>")?;
200 writeln!(file, "<div class=\"container\">")?;
201
202 writeln!(file, "<div class=\"header\">")?;
204 writeln!(
205 file,
206 " <img src=\"https://www.pkgsrc.org/img/pkgsrc-square.png\" alt=\"pkgsrc\" class=\"logo\">"
207 )?;
208 writeln!(file, " <h1>Build Report</h1>")?;
209 writeln!(file, "</div>")?;
210
211 write_summary_stats(&mut file, summary)?;
213
214 write_failed_section(&mut file, &failed_info, logdir)?;
216
217 if !summary.scanfail.is_empty() {
219 write_scanfail_section(&mut file, &summary.scanfail)?;
220 }
221
222 write_skipped_section(&mut file, &skipped)?;
224
225 write_success_section(&mut file, &succeeded, logdir)?;
227
228 writeln!(
230 file,
231 "<p style=\"color: #666; font-size: 0.9em; text-align: center; margin-top: 40px;\">"
232 )?;
233 writeln!(
234 file,
235 " Generated by <a href=\"https://github.com/jperkin/bob\">bob</a>"
236 )?;
237 writeln!(file, "</p>")?;
238
239 writeln!(file, "</div>")?;
240 writeln!(file, "</body>")?;
241 writeln!(file, "</html>")?;
242
243 Ok(())
244}
245
246fn write_styles(file: &mut fs::File) -> Result<()> {
247 writeln!(file, " <style>")?;
248 writeln!(
249 file,
250 " body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; background: #fff; }}"
251 )?;
252 writeln!(file, " .container {{ max-width: 1400px; margin: 0 auto; }}")?;
253 writeln!(
254 file,
255 " .header {{ display: flex; align-items: center; gap: 20px; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 3px solid #f37021; }}"
256 )?;
257 writeln!(file, " .logo {{ height: 48px; }}")?;
258 writeln!(file, " h1 {{ color: #f37021; margin: 0; }}")?;
259 writeln!(
260 file,
261 " .summary {{ display: flex; gap: 20px; margin-bottom: 30px; flex-wrap: wrap; }}"
262 )?;
263 writeln!(
264 file,
265 " .stat {{ background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); min-width: 150px; }}"
266 )?;
267 writeln!(
268 file,
269 " .stat h2 {{ margin: 0 0 10px 0; font-size: 14px; color: #666; text-transform: uppercase; }}"
270 )?;
271 writeln!(
272 file,
273 " .stat .value {{ font-size: 36px; font-weight: bold; }}"
274 )?;
275 writeln!(file, " .stat.success .value {{ color: #28a745; }}")?;
276 writeln!(file, " .stat.failed .value {{ color: #dc3545; }}")?;
277 writeln!(file, " .stat.skipped .value {{ color: #ffc107; }}")?;
278 writeln!(file, " .stat.scan-failed .value {{ color: #fd7e14; }}")?;
279 writeln!(
280 file,
281 " .stat.duration .value {{ color: #17a2b8; font-size: 24px; }}"
282 )?;
283 writeln!(
284 file,
285 " .section {{ background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }}"
286 )?;
287 writeln!(
288 file,
289 " .section h2 {{ margin-top: 0; border-bottom: 2px solid #eee; padding-bottom: 10px; }}"
290 )?;
291 writeln!(
292 file,
293 " .section.success h2 {{ color: #28a745; border-color: #28a745; }}"
294 )?;
295 writeln!(
296 file,
297 " .section.failed h2 {{ color: #dc3545; border-color: #dc3545; }}"
298 )?;
299 writeln!(
300 file,
301 " .section.skipped h2 {{ color: #856404; border-color: #ffc107; }}"
302 )?;
303 writeln!(
304 file,
305 " .section.scan-failed h2 {{ color: #fd7e14; border-color: #fd7e14; }}"
306 )?;
307 writeln!(
308 file,
309 " table {{ width: 100%; border-collapse: collapse; font-size: 0.9em; }}"
310 )?;
311 writeln!(
312 file,
313 " th, td {{ text-align: left; padding: 12px 8px; border-bottom: 1px solid #eee; }}"
314 )?;
315 writeln!(
316 file,
317 " th {{ background: #ffeee6; font-weight: 600; cursor: pointer; user-select: none; }}"
318 )?;
319 writeln!(file, " th:hover {{ background: #ffddc9; }}")?;
320 writeln!(
321 file,
322 " th .sort-indicator {{ margin-left: 5px; color: #999; }}"
323 )?;
324 writeln!(
325 file,
326 " th.sort-asc .sort-indicator::after {{ content: ' ▲'; }}"
327 )?;
328 writeln!(
329 file,
330 " th.sort-desc .sort-indicator::after {{ content: ' ▼'; }}"
331 )?;
332 writeln!(file, " tr:hover {{ background: #fef6f3; }}")?;
333 writeln!(file, " a {{ color: #d35400; text-decoration: none; }}")?;
334 writeln!(file, " a:hover {{ text-decoration: underline; }}")?;
335 writeln!(file, " .reason {{ color: #666; font-size: 0.9em; }}")?;
336 writeln!(file, " .duration {{ color: #666; font-size: 0.9em; }}")?;
337 writeln!(file, " .empty {{ color: #666; font-style: italic; }}")?;
338 writeln!(
339 file,
340 " .phase-links {{ display: flex; gap: 6px; flex-wrap: wrap; }}"
341 )?;
342 writeln!(
343 file,
344 " .phase-link {{ padding: 2px 8px; border-radius: 4px; font-size: 0.85em; background: #ffeee6; }}"
345 )?;
346 writeln!(file, " .phase-link:hover {{ background: #ffddc9; }}")?;
347 writeln!(
348 file,
349 " .phase-link.failed {{ background: #f8d7da; color: #721c24; font-weight: bold; }}"
350 )?;
351 writeln!(
352 file,
353 " .breaks-count {{ font-weight: bold; color: #dc3545; }}"
354 )?;
355 writeln!(file, " .breaks-zero {{ color: #666; }}")?;
356 writeln!(file, " </style>")?;
357 Ok(())
358}
359
360fn write_sort_script(file: &mut fs::File) -> Result<()> {
361 writeln!(file, " <script>")?;
362 writeln!(file, " function sortTable(table, colIdx, type) {{")?;
363 writeln!(file, " const tbody = table.querySelector('tbody');")?;
364 writeln!(
365 file,
366 " const rows = Array.from(tbody.querySelectorAll('tr'));"
367 )?;
368 writeln!(file, " const th = table.querySelectorAll('th')[colIdx];")?;
369 writeln!(file, " const isAsc = th.classList.contains('sort-asc');")?;
370 writeln!(file, " ")?;
371 writeln!(file, " // Remove sort classes from all headers")?;
372 writeln!(
373 file,
374 " table.querySelectorAll('th').forEach(h => h.classList.remove('sort-asc', 'sort-desc'));"
375 )?;
376 writeln!(file, " ")?;
377 writeln!(file, " // Add appropriate class to clicked header")?;
378 writeln!(
379 file,
380 " th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');"
381 )?;
382 writeln!(file, " ")?;
383 writeln!(file, " rows.sort((a, b) => {{")?;
384 writeln!(
385 file,
386 " let aVal = a.cells[colIdx].getAttribute('data-sort') || a.cells[colIdx].textContent;"
387 )?;
388 writeln!(
389 file,
390 " let bVal = b.cells[colIdx].getAttribute('data-sort') || b.cells[colIdx].textContent;"
391 )?;
392 writeln!(file, " ")?;
393 writeln!(file, " if (type === 'num') {{")?;
394 writeln!(file, " aVal = parseFloat(aVal) || 0;")?;
395 writeln!(file, " bVal = parseFloat(bVal) || 0;")?;
396 writeln!(file, " return isAsc ? bVal - aVal : aVal - bVal;")?;
397 writeln!(file, " }} else {{")?;
398 writeln!(
399 file,
400 " return isAsc ? bVal.localeCompare(aVal) : aVal.localeCompare(bVal);"
401 )?;
402 writeln!(file, " }}")?;
403 writeln!(file, " }});")?;
404 writeln!(file, " ")?;
405 writeln!(file, " rows.forEach(row => tbody.appendChild(row));")?;
406 writeln!(file, " }}")?;
407 writeln!(file, " </script>")?;
408 Ok(())
409}
410
411fn write_summary_stats(
412 file: &mut fs::File,
413 summary: &BuildSummary,
414) -> Result<()> {
415 let duration_secs = summary.duration.as_secs();
416 let hours = duration_secs / 3600;
417 let minutes = (duration_secs % 3600) / 60;
418 let seconds = duration_secs % 60;
419 let duration_str = if hours > 0 {
420 format!("{}h {}m {}s", hours, minutes, seconds)
421 } else if minutes > 0 {
422 format!("{}m {}s", minutes, seconds)
423 } else {
424 format!("{}s", seconds)
425 };
426
427 let c = summary.counts();
428 let s = &c.skipped;
429 let skipped_count = c.up_to_date
430 + s.pkg_skip
431 + s.pkg_fail
432 + s.unresolved
433 + s.indirect_skip
434 + s.indirect_fail;
435 writeln!(file, "<div class=\"summary\">")?;
436 writeln!(
437 file,
438 " <div class=\"stat success\"><h2>Succeeded</h2><div class=\"value\">{}</div></div>",
439 c.success
440 )?;
441 writeln!(
442 file,
443 " <div class=\"stat failed\"><h2>Failed</h2><div class=\"value\">{}</div></div>",
444 c.failed
445 )?;
446 writeln!(
447 file,
448 " <div class=\"stat skipped\"><h2>Skipped</h2><div class=\"value\">{}</div></div>",
449 skipped_count
450 )?;
451 if c.scanfail > 0 {
452 writeln!(
453 file,
454 " <div class=\"stat scan-failed\"><h2>Scan Failed</h2><div class=\"value\">{}</div></div>",
455 c.scanfail
456 )?;
457 }
458 writeln!(
459 file,
460 " <div class=\"stat duration\"><h2>Duration</h2><div class=\"value\">{}</div></div>",
461 duration_str
462 )?;
463 writeln!(file, "</div>")?;
464 Ok(())
465}
466
467fn generate_phase_links(
468 pkg_name: &str,
469 log_dir: &Path,
470 failed_phase: Option<&str>,
471) -> String {
472 if !log_dir.exists() {
473 return "-".to_string();
474 }
475
476 let mut links = Vec::new();
477 for (phase_name, log_file) in BUILD_PHASES {
478 let log_path = log_dir.join(log_file);
479 if log_path.exists() {
480 let is_failed = failed_phase == Some(*phase_name);
481 let class =
482 if is_failed { "phase-link failed" } else { "phase-link" };
483 links.push(format!(
484 "<a href=\"{}/{}\" class=\"{}\">{}</a>",
485 pkg_name, log_file, class, phase_name
486 ));
487 }
488 }
489 if links.is_empty() {
490 "-".to_string()
491 } else {
492 format!("<div class=\"phase-links\">{}</div>", links.join(""))
493 }
494}
495
496fn write_failed_section(
497 file: &mut fs::File,
498 failed_info: &[FailedPackageInfo],
499 logdir: &Path,
500) -> Result<()> {
501 writeln!(file, "<div class=\"section failed\">")?;
502 writeln!(file, " <h2>Failed Packages ({})</h2>", failed_info.len())?;
503
504 if failed_info.is_empty() {
505 writeln!(file, " <p class=\"empty\">No failed packages</p>")?;
506 } else {
507 writeln!(file, " <table id=\"failed-table\">")?;
508 writeln!(file, " <thead><tr>")?;
509 writeln!(
510 file,
511 " <th onclick=\"sortTable(document.getElementById('failed-table'), 0, 'str')\">Package<span class=\"sort-indicator\"></span></th>"
512 )?;
513 writeln!(
514 file,
515 " <th onclick=\"sortTable(document.getElementById('failed-table'), 1, 'str')\">Path<span class=\"sort-indicator\"></span></th>"
516 )?;
517 writeln!(
518 file,
519 " <th onclick=\"sortTable(document.getElementById('failed-table'), 2, 'num')\" class=\"sort-desc\">Breaks<span class=\"sort-indicator\"></span></th>"
520 )?;
521 writeln!(
522 file,
523 " <th onclick=\"sortTable(document.getElementById('failed-table'), 3, 'num')\">Duration<span class=\"sort-indicator\"></span></th>"
524 )?;
525 writeln!(file, " <th>Build Logs</th>")?;
526 writeln!(file, " </tr></thead>")?;
527 writeln!(file, " <tbody>")?;
528
529 for info in failed_info {
530 let pkg_name = info.result.pkgname.pkgname();
531 let pkgpath = info
532 .result
533 .pkgpath
534 .as_ref()
535 .map(|p| p.as_path().display().to_string())
536 .unwrap_or_default();
537
538 let breaks_class = if info.breaks_count > 0 {
539 "breaks-count"
540 } else {
541 "breaks-zero"
542 };
543
544 let dur_secs = info.result.duration.as_secs();
545 let duration = if dur_secs >= 60 {
546 format!("{}m {}s", dur_secs / 60, dur_secs % 60)
547 } else {
548 format!("{}s", dur_secs)
549 };
550
551 let pkg_link = match &info.failed_log {
553 Some(log) => {
554 format!("<a href=\"{}/{}\">{}</a>", pkg_name, log, pkg_name)
555 }
556 None => pkg_name.to_string(),
557 };
558
559 let log_dir = logdir.join(pkg_name);
560 let phase_links = generate_phase_links(
561 pkg_name,
562 &log_dir,
563 info.failed_phase.as_deref(),
564 );
565
566 writeln!(
567 file,
568 " <tr><td>{}</td><td>{}</td><td class=\"{}\" data-sort=\"{}\">{}</td><td class=\"duration\" data-sort=\"{}\">{}</td><td>{}</td></tr>",
569 pkg_link,
570 pkgpath,
571 breaks_class,
572 info.breaks_count,
573 info.breaks_count,
574 dur_secs,
575 duration,
576 phase_links
577 )?;
578 }
579
580 writeln!(file, " </tbody>")?;
581 writeln!(file, " </table>")?;
582 }
583 writeln!(file, "</div>")?;
584 Ok(())
585}
586
587fn write_skipped_section(
588 file: &mut fs::File,
589 skipped: &[&BuildResult],
590) -> Result<()> {
591 writeln!(file, "<div class=\"section skipped\">")?;
592 writeln!(file, " <h2>Skipped Packages ({})</h2>", skipped.len())?;
593
594 if skipped.is_empty() {
595 writeln!(file, " <p class=\"empty\">No skipped packages</p>")?;
596 } else {
597 writeln!(file, " <table id=\"skipped-table\">")?;
598 writeln!(file, " <thead><tr>")?;
599 writeln!(
600 file,
601 " <th onclick=\"sortTable(document.getElementById('skipped-table'), 0, 'str')\">Package<span class=\"sort-indicator\"></span></th>"
602 )?;
603 writeln!(
604 file,
605 " <th onclick=\"sortTable(document.getElementById('skipped-table'), 1, 'str')\">Path<span class=\"sort-indicator\"></span></th>"
606 )?;
607 writeln!(
608 file,
609 " <th onclick=\"sortTable(document.getElementById('skipped-table'), 2, 'str')\">Status<span class=\"sort-indicator\"></span></th>"
610 )?;
611 writeln!(
612 file,
613 " <th onclick=\"sortTable(document.getElementById('skipped-table'), 3, 'str')\">Reason<span class=\"sort-indicator\"></span></th>"
614 )?;
615 writeln!(file, " </tr></thead>")?;
616 writeln!(file, " <tbody>")?;
617
618 for result in skipped {
619 let (status, reason) = match &result.outcome {
620 BuildOutcome::UpToDate => ("up-to-date", String::new()),
621 BuildOutcome::Skipped(r) => (r.status(), r.to_string()),
622 BuildOutcome::Success | BuildOutcome::Failed(_) => continue,
623 };
624 let pkgpath = result
625 .pkgpath
626 .as_ref()
627 .map(|p| p.as_path().display().to_string())
628 .unwrap_or_default();
629 writeln!(
630 file,
631 " <tr><td>{}</td><td>{}</td><td>{}</td><td class=\"reason\">{}</td></tr>",
632 result.pkgname.pkgname(),
633 pkgpath,
634 status,
635 reason
636 )?;
637 }
638
639 writeln!(file, " </tbody>")?;
640 writeln!(file, " </table>")?;
641 }
642 writeln!(file, "</div>")?;
643 Ok(())
644}
645
646fn write_success_section(
647 file: &mut fs::File,
648 succeeded: &[&BuildResult],
649 logdir: &Path,
650) -> Result<()> {
651 writeln!(file, "<div class=\"section success\">")?;
652 writeln!(file, " <h2>Successful Packages ({})</h2>", succeeded.len())?;
653
654 if succeeded.is_empty() {
655 writeln!(file, " <p class=\"empty\">No successful packages</p>")?;
656 } else {
657 writeln!(file, " <table id=\"success-table\">")?;
658 writeln!(file, " <thead><tr>")?;
659 writeln!(
660 file,
661 " <th onclick=\"sortTable(document.getElementById('success-table'), 0, 'str')\">Package<span class=\"sort-indicator\"></span></th>"
662 )?;
663 writeln!(
664 file,
665 " <th onclick=\"sortTable(document.getElementById('success-table'), 1, 'str')\">Path<span class=\"sort-indicator\"></span></th>"
666 )?;
667 writeln!(
668 file,
669 " <th onclick=\"sortTable(document.getElementById('success-table'), 2, 'num')\">Duration<span class=\"sort-indicator\"></span></th>"
670 )?;
671 writeln!(file, " <th>Build Logs</th>")?;
672 writeln!(file, " </tr></thead>")?;
673 writeln!(file, " <tbody>")?;
674
675 for result in succeeded {
676 let pkg_name = result.pkgname.pkgname();
677 let pkgpath = result
678 .pkgpath
679 .as_ref()
680 .map(|p| p.as_path().display().to_string())
681 .unwrap_or_default();
682 let dur_secs = result.duration.as_secs();
683 let duration = if dur_secs >= 60 {
684 format!("{}m {}s", dur_secs / 60, dur_secs % 60)
685 } else {
686 format!("{}s", dur_secs)
687 };
688
689 let log_dir = logdir.join(pkg_name);
690 let phase_links = generate_phase_links(pkg_name, &log_dir, None);
691
692 writeln!(
693 file,
694 " <tr><td>{}</td><td>{}</td><td class=\"duration\" data-sort=\"{}\">{}</td><td>{}</td></tr>",
695 pkg_name, pkgpath, dur_secs, duration, phase_links
696 )?;
697 }
698
699 writeln!(file, " </tbody>")?;
700 writeln!(file, " </table>")?;
701 }
702 writeln!(file, "</div>")?;
703 Ok(())
704}
705
706fn write_scanfail_section(
707 file: &mut fs::File,
708 scanfail: &[(PkgPath, String)],
709) -> Result<()> {
710 writeln!(file, "<div class=\"section scan-failed\">")?;
711 writeln!(file, " <h2>Scan Failed Packages ({})</h2>", scanfail.len())?;
712
713 writeln!(file, " <table id=\"scan-failed-table\">")?;
714 writeln!(file, " <thead><tr>")?;
715 writeln!(
716 file,
717 " <th onclick=\"sortTable(document.getElementById('scan-failed-table'), 0, 'str')\">Path<span class=\"sort-indicator\"></span></th>"
718 )?;
719 writeln!(
720 file,
721 " <th onclick=\"sortTable(document.getElementById('scan-failed-table'), 1, 'str')\">Error<span class=\"sort-indicator\"></span></th>"
722 )?;
723 writeln!(file, " </tr></thead>")?;
724 writeln!(file, " <tbody>")?;
725
726 for (pkgpath, error_msg) in scanfail {
727 let path_str = pkgpath.as_path().display().to_string();
728 let error = error_msg
730 .replace('&', "&")
731 .replace('<', "<")
732 .replace('>', ">");
733 writeln!(
734 file,
735 " <tr><td>{}</td><td class=\"reason\">{}</td></tr>",
736 path_str, error
737 )?;
738 }
739
740 writeln!(file, " </tbody>")?;
741 writeln!(file, " </table>")?;
742 writeln!(file, "</div>")?;
743 Ok(())
744}