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).ok().map(|s| s.trim().to_string())
85}
86
87/// Generate an HTML build report from database.
88///
89/// Reads build results from the database, ensuring accurate duration and
90/// breaks counts even for interrupted or resumed builds.
91pub 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    // Add pre-failed packages (those with skip_reason or fail_reason)
101    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    // Add calculated indirect failures for packages without build results
112    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
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| {
148            matches!(
149                r.outcome,
150                BuildOutcome::UpToDate | BuildOutcome::Skipped(_)
151            )
152        })
153        .collect();
154
155    // Collect failed packages with additional info
156    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    // Sort failed by breaks_count descending, then by name
177    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    // 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!(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            // Package name links to the failed log if available
552            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        // Escape HTML in error message
729        let error = error_msg
730            .replace('&', "&amp;")
731            .replace('<', "&lt;")
732            .replace('>', "&gt;");
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}