Skip to main content

bob/
report.rs

1/*
2 * Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
3 *
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted, provided that the above
6 * copyright notice and this permission notice appear in all copies.
7 *
8 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15 */
16
17//! HTML build report generation.
18//!
19//! This module generates HTML reports summarizing build results. Reports include:
20//!
21//! - Summary statistics (succeeded, failed, skipped counts)
22//! - Failed packages with links to build logs
23//! - Skipped packages with reasons
24//! - Successfully built packages with build times
25//!
26//! # Report Structure
27//!
28//! The generated HTML report is self-contained with embedded CSS and JavaScript.
29//! Tables are sortable by clicking column headers.
30//!
31//! ## Failed Packages Section
32//!
33//! Shows packages that failed to build, sorted by the number of other packages
34//! they block. Each entry includes:
35//!
36//! - Package name and path
37//! - Number of packages blocked by this failure
38//! - The build phase where failure occurred
39//! - Links to individual phase logs
40//!
41//! ## Skipped Packages Section
42//!
43//! Shows packages that were not built, with the reason for skipping
44//! (e.g., "Dependency X failed", "up-to-date").
45//!
46//! ## Successful Packages Section
47//!
48//! Shows all successfully built packages with their build duration.
49
50use 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
60/// Build phases in order, with their log file names.
61const 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
73/// Information about a failed package for reporting.
74struct FailedPackageInfo<'a> {
75    result: &'a BuildResult,
76    breaks_count: usize,
77    failed_phase: Option<String>,
78    failed_log: Option<String>,
79}
80
81/// Read the failed phase from the .stage file in the log directory.
82fn 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
89/// Generate an HTML build report from database.
90///
91/// Reads build results from the database, ensuring accurate duration and
92/// breaks counts even for interrupted or resumed builds.
93pub 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    // Add pre-failed packages (those with skip_reason or fail_reason)
99    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    // Add calculated indirect failures for packages without build results
110    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
129/// Internal implementation for report generation.
130fn 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    // Collect and sort results
143    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    // Collect failed packages with additional info
151    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    // Sort failed by breaks_count descending, then by name
177    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    // Write HTML header
187    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    // Header with pkgsrc logo
203    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    // Summary stats
212    write_summary_stats(&mut file, summary)?;
213
214    // Failed packages section
215    write_failed_section(&mut file, &failed_info, logdir)?;
216
217    // Scan failed section (if any)
218    if !summary.scanfail.is_empty() {
219        write_scanfail_section(&mut file, &summary.scanfail)?;
220    }
221
222    // Skipped packages section
223    write_skipped_section(&mut file, &skipped)?;
224
225    // Successful packages section
226    write_success_section(&mut file, &succeeded, logdir)?;
227
228    // Footer
229    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            // Package name links to the failed log if available
553            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        // Escape HTML in error message
721        let error = error_msg
722            .replace('&', "&amp;")
723            .replace('<', "&lt;")
724            .replace('>', "&gt;");
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}