bob/
report.rs

1/*
2 * Copyright (c) 2025 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//! # Usage
27//!
28//! ```no_run
29//! use bob::{write_html_report, Database};
30//! use std::path::Path;
31//!
32//! # fn example(db: &Database) -> anyhow::Result<()> {
33//! let logdir = Path::new("/data/bob/logs");
34//! write_html_report(db, logdir, &logdir.join("report.html"))?;
35//! # Ok(())
36//! # }
37//! ```
38//!
39//! # Report Structure
40//!
41//! The generated HTML report is self-contained with embedded CSS and JavaScript.
42//! Tables are sortable by clicking column headers.
43//!
44//! ## Failed Packages Section
45//!
46//! Shows packages that failed to build, sorted by the number of other packages
47//! they block. Each entry includes:
48//!
49//! - Package name and path
50//! - Number of packages blocked by this failure
51//! - The build phase where failure occurred
52//! - Links to individual phase logs
53//!
54//! ## Skipped Packages Section
55//!
56//! Shows packages that were not built, with the reason for skipping
57//! (e.g., "Dependency X failed", "up-to-date").
58//!
59//! ## Successful Packages Section
60//!
61//! Shows all successfully built packages with their build duration.
62
63use 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
72/// Build phases in order, with their log file names.
73const 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
85/// Information about a failed package for reporting.
86struct FailedPackageInfo<'a> {
87    result: &'a BuildResult,
88    breaks_count: usize,
89    failed_phase: Option<String>,
90    failed_log: Option<String>,
91}
92
93/// Read the failed phase from the .stage file in the log directory.
94fn 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
99/// Generate an HTML build report from database.
100///
101/// Reads build results from the database, ensuring accurate duration and
102/// breaks counts even for interrupted or resumed builds.
103pub 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    // Add pre-failed packages (those with skip_reason or fail_reason)
113    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    // Add calculated indirect failures for packages without build results
124    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
139/// Internal implementation for report generation.
140fn 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    // Collect and sort results
153    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    // Collect failed packages with additional info
169    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    // Sort failed by breaks_count descending, then by name
190    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    // Write HTML header
200    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    // Header with pkgsrc logo
216    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    // Summary stats
225    write_summary_stats(&mut file, summary)?;
226
227    // Failed packages section
228    write_failed_section(&mut file, &failed_info, logdir)?;
229
230    // Scan failed section (if any)
231    if !summary.scan_failed.is_empty() {
232        write_scan_failed_section(&mut file, &summary.scan_failed)?;
233    }
234
235    // Skipped packages section
236    write_skipped_section(&mut file, &skipped)?;
237
238    // Successful packages section
239    write_success_section(&mut file, &succeeded, logdir)?;
240
241    // Footer
242    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            // Package name links to the failed log if available
561            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        // Escape HTML in error message
760        let error = failure
761            .error
762            .replace('&', "&amp;")
763            .replace('<', "&lt;")
764            .replace('>', "&gt;");
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}