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