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