bob/
build.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//! Parallel package builds.
18//!
19//! This module provides the [`Build`] struct for building packages in parallel
20//! across multiple sandboxes. Packages are scheduled using a dependency graph
21//! to ensure correct build order.
22//!
23//! # Build Process
24//!
25//! 1. Create build sandboxes (one per `build_threads`)
26//! 2. Execute pre-build script in each sandbox
27//! 3. Build packages in parallel, respecting dependencies
28//! 4. Execute post-build script after each package
29//! 5. Destroy sandboxes and generate report
30//!
31//! # Build Phases
32//!
33//! Each package goes through these phases in turn:
34//!
35//! - `pre-clean` - Clean any previous build artifacts
36//! - `depends` - Install required dependencies
37//! - `checksum` - Verify distfile checksums
38//! - `configure` - Configure the build
39//! - `build` - Compile the package
40//! - `install` - Install to staging area
41//! - `package` - Create binary package
42//! - `deinstall` - Test package removal (non-bootstrap only)
43//! - `clean` - Clean up build artifacts
44//!
45//! # Example
46//!
47//! ```no_run
48//! use bob::{Build, BuildOptions, Config, Database, RunContext, Scan};
49//! use std::sync::Arc;
50//! use std::sync::atomic::AtomicBool;
51//!
52//! let config = Config::load(None, false)?;
53//! let db_path = config.logdir().join("bob").join("bob.db");
54//! let db = Database::open(&db_path)?;
55//! let mut scan = Scan::new(&config);
56//! // Add packages...
57//! let ctx = RunContext::new(Arc::new(AtomicBool::new(false)));
58//! scan.start(&ctx, &db)?;
59//! let result = scan.resolve(&db)?;
60//!
61//! let mut build = Build::new(&config, result.buildable, BuildOptions::default());
62//! let summary = build.start(&ctx, &db)?;
63//!
64//! println!("Built {} packages", summary.success_count());
65//! # Ok::<(), anyhow::Error>(())
66//! ```
67
68use crate::scan::ResolvedIndex;
69use crate::scan::ScanFailure;
70use crate::tui::{MultiProgress, format_duration};
71use crate::{Config, RunContext, Sandbox};
72use anyhow::{Context, bail};
73use glob::Pattern;
74use indexmap::IndexMap;
75use pkgsrc::{PkgName, PkgPath};
76use std::collections::{HashMap, HashSet, VecDeque};
77use std::fs::{self, File, OpenOptions};
78use std::path::{Path, PathBuf};
79use std::process::{Command, ExitStatus, Stdio};
80use std::sync::atomic::{AtomicBool, Ordering};
81use std::sync::{Arc, Mutex, mpsc, mpsc::Sender};
82use std::time::{Duration, Instant};
83use tracing::{debug, error, info, trace, warn};
84
85/// Build stages in order of execution.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87enum Stage {
88    PreClean,
89    Depends,
90    Checksum,
91    Configure,
92    Build,
93    Install,
94    Package,
95    Deinstall,
96    Clean,
97}
98
99impl Stage {
100    fn as_str(&self) -> &'static str {
101        match self {
102            Stage::PreClean => "pre-clean",
103            Stage::Depends => "depends",
104            Stage::Checksum => "checksum",
105            Stage::Configure => "configure",
106            Stage::Build => "build",
107            Stage::Install => "install",
108            Stage::Package => "package",
109            Stage::Deinstall => "deinstall",
110            Stage::Clean => "clean",
111        }
112    }
113}
114
115/// Result of a package build.
116#[derive(Debug)]
117enum PkgBuildResult {
118    Success,
119    Failed,
120    Skipped,
121}
122
123/// How to run a command.
124#[derive(Debug, Clone, Copy)]
125enum RunAs {
126    Root,
127    User,
128}
129
130/// Callback for status updates during build.
131trait BuildCallback: Send {
132    fn stage(&mut self, stage: &str);
133}
134
135/// Package builder that executes build stages.
136struct PkgBuilder<'a> {
137    config: &'a Config,
138    sandbox: &'a Sandbox,
139    sandbox_id: usize,
140    pkginfo: &'a ResolvedIndex,
141    logdir: PathBuf,
142    build_user: Option<String>,
143    envs: Vec<(String, String)>,
144    output_tx: Option<Sender<ChannelCommand>>,
145    options: &'a BuildOptions,
146}
147
148impl<'a> PkgBuilder<'a> {
149    fn new(
150        config: &'a Config,
151        sandbox: &'a Sandbox,
152        sandbox_id: usize,
153        pkginfo: &'a ResolvedIndex,
154        envs: Vec<(String, String)>,
155        output_tx: Option<Sender<ChannelCommand>>,
156        options: &'a BuildOptions,
157    ) -> Self {
158        let logdir = config.logdir().join(pkginfo.pkgname.pkgname());
159        let build_user = config.build_user().map(|s| s.to_string());
160        Self {
161            config,
162            sandbox,
163            sandbox_id,
164            pkginfo,
165            logdir,
166            build_user,
167            envs,
168            output_tx,
169            options,
170        }
171    }
172
173    /// Run a command in the sandbox and capture its stdout.
174    fn run_cmd(&self, cmd: &Path, args: &[&str]) -> Option<String> {
175        let mut command = self.sandbox.command(self.sandbox_id, cmd);
176        command.args(args);
177        self.apply_envs(&mut command, &[]);
178        match command.output() {
179            Ok(output) if output.status.success() => {
180                Some(String::from_utf8_lossy(&output.stdout).into_owned())
181            }
182            Ok(output) => {
183                let stderr = String::from_utf8_lossy(&output.stderr);
184                debug!(
185                    cmd = %cmd.display(),
186                    exit_code = ?output.status.code(),
187                    stderr = %stderr.trim(),
188                    "command failed"
189                );
190                None
191            }
192            Err(e) => {
193                debug!(cmd = %cmd.display(), error = %e, "command execution error");
194                None
195            }
196        }
197    }
198
199    /// Check if the package is already up-to-date.
200    fn check_up_to_date(&self) -> anyhow::Result<bool> {
201        let packages =
202            self.config.packages().context("pkgsrc.packages not configured")?;
203        let pkgtools =
204            self.config.pkgtools().context("pkgsrc.pkgtools not configured")?;
205
206        let pkgname = self.pkginfo.pkgname.pkgname();
207        let pkgfile = packages.join("All").join(format!("{}.tgz", pkgname));
208
209        // Check if package file exists
210        if !pkgfile.exists() {
211            debug!(pkgname, path = %pkgfile.display(), "package file not found");
212            return Ok(false);
213        }
214
215        let pkgfile_str = pkgfile.to_string_lossy();
216        let pkg_info = pkgtools.join("pkg_info");
217        let pkg_admin = pkgtools.join("pkg_admin");
218
219        // Get BUILD_INFO and verify source files
220        let Some(build_info) = self.run_cmd(&pkg_info, &["-qb", &pkgfile_str])
221        else {
222            debug!(pkgname, "pkg_info -qb failed or returned empty");
223            return Ok(false);
224        };
225        debug!(
226            pkgname,
227            lines = build_info.lines().count(),
228            "checking BUILD_INFO"
229        );
230
231        for line in build_info.lines() {
232            let Some((file, file_id)) = line.split_once(':') else {
233                continue;
234            };
235            let file_id = file_id.trim();
236            if file.is_empty() || file_id.is_empty() {
237                continue;
238            }
239
240            let src_file = self.config.pkgsrc().join(file);
241            if !src_file.exists() {
242                debug!(pkgname, file, "source file missing");
243                return Ok(false);
244            }
245
246            if file_id.starts_with("$NetBSD") {
247                // CVS ID comparison - extract $NetBSD...$ from actual file
248                let Ok(content) = std::fs::read_to_string(&src_file) else {
249                    return Ok(false);
250                };
251                let id = content.lines().find_map(|line| {
252                    if let Some(start) = line.find("$NetBSD") {
253                        if let Some(end) = line[start + 1..].find('$') {
254                            return Some(&line[start..start + 1 + end + 1]);
255                        }
256                    }
257                    None
258                });
259                if id != Some(file_id) {
260                    debug!(pkgname, file, "CVS ID mismatch");
261                    return Ok(false);
262                }
263            } else {
264                // Hash comparison
265                let src_file_str = src_file.to_string_lossy();
266                let Some(hash) =
267                    self.run_cmd(&pkg_admin, &["digest", &src_file_str])
268                else {
269                    debug!(pkgname, file, "pkg_admin digest failed");
270                    return Ok(false);
271                };
272                let hash = hash.trim();
273                if hash != file_id {
274                    debug!(
275                        pkgname,
276                        file,
277                        path = %src_file.display(),
278                        expected = file_id,
279                        actual = hash,
280                        "hash mismatch"
281                    );
282                    return Ok(false);
283                }
284            }
285        }
286
287        // Get package dependencies and verify
288        let Some(pkg_deps) = self.run_cmd(&pkg_info, &["-qN", &pkgfile_str])
289        else {
290            return Ok(false);
291        };
292
293        // Build sets of recorded vs expected dependencies
294        let recorded_deps: HashSet<&str> = pkg_deps
295            .lines()
296            .map(|l| l.trim())
297            .filter(|l| !l.is_empty())
298            .collect();
299        let expected_deps: HashSet<&str> =
300            self.pkginfo.depends.iter().map(|d| d.pkgname()).collect();
301
302        // If dependency list has changed in any way, rebuild
303        if recorded_deps != expected_deps {
304            debug!(
305                pkgname,
306                recorded = recorded_deps.len(),
307                expected = expected_deps.len(),
308                "dependency list changed"
309            );
310            return Ok(false);
311        }
312
313        let pkgfile_mtime = match pkgfile.metadata().and_then(|m| m.modified())
314        {
315            Ok(t) => t,
316            Err(_) => return Ok(false),
317        };
318
319        // Check each dependency package exists and is not newer
320        for dep in &recorded_deps {
321            let dep_pkg = packages.join("All").join(format!("{}.tgz", dep));
322            if !dep_pkg.exists() {
323                debug!(pkgname, dep, "dependency package missing");
324                return Ok(false);
325            }
326
327            let dep_mtime = match dep_pkg.metadata().and_then(|m| m.modified())
328            {
329                Ok(t) => t,
330                Err(_) => return Ok(false),
331            };
332
333            if dep_mtime > pkgfile_mtime {
334                debug!(pkgname, dep, "dependency is newer");
335                return Ok(false);
336            }
337        }
338
339        debug!(pkgname, "package is up-to-date");
340        Ok(true)
341    }
342
343    /// Run the full build process.
344    fn build<C: BuildCallback>(
345        &self,
346        callback: &mut C,
347    ) -> anyhow::Result<PkgBuildResult> {
348        let pkgname = self.pkginfo.pkgname.pkgname();
349        let Some(pkgpath) = &self.pkginfo.pkg_location else {
350            bail!("Could not get PKGPATH for {}", pkgname);
351        };
352
353        // Check if package is already up-to-date (skip check if force rebuild)
354        if !self.options.force_rebuild && self.check_up_to_date()? {
355            return Ok(PkgBuildResult::Skipped);
356        }
357
358        // Clean up and create log directory
359        if self.logdir.exists() {
360            fs::remove_dir_all(&self.logdir)?;
361        }
362        fs::create_dir_all(&self.logdir)?;
363
364        // Create work.log and chown to build_user if set
365        let work_log = self.logdir.join("work.log");
366        File::create(&work_log)?;
367        if let Some(ref user) = self.build_user {
368            let bob_log = File::options()
369                .create(true)
370                .append(true)
371                .open(self.logdir.join("bob.log"))?;
372            let bob_log_err = bob_log.try_clone()?;
373            let _ = Command::new("chown")
374                .arg(user)
375                .arg(&work_log)
376                .stdout(bob_log)
377                .stderr(bob_log_err)
378                .status();
379        }
380
381        let pkgdir = self.config.pkgsrc().join(pkgpath.as_path());
382
383        // Pre-clean
384        callback.stage(Stage::PreClean.as_str());
385        self.run_make_stage(
386            Stage::PreClean,
387            &pkgdir,
388            &["clean"],
389            RunAs::Root,
390            false,
391        )?;
392
393        // Install dependencies
394        if !self.pkginfo.depends.is_empty() {
395            callback.stage(Stage::Depends.as_str());
396            let _ = self.write_stage(Stage::Depends);
397            if !self.install_dependencies()? {
398                return Ok(PkgBuildResult::Failed);
399            }
400        }
401
402        // Checksum
403        callback.stage(Stage::Checksum.as_str());
404        if !self.run_make_stage(
405            Stage::Checksum,
406            &pkgdir,
407            &["checksum"],
408            RunAs::Root,
409            true,
410        )? {
411            return Ok(PkgBuildResult::Failed);
412        }
413
414        // Configure
415        callback.stage(Stage::Configure.as_str());
416        let configure_log = self.logdir.join("configure.log");
417        if !self.run_usergroup_if_needed(
418            Stage::Configure,
419            &pkgdir,
420            &configure_log,
421        )? {
422            return Ok(PkgBuildResult::Failed);
423        }
424        if !self.run_make_stage(
425            Stage::Configure,
426            &pkgdir,
427            &["configure"],
428            self.build_run_as(),
429            true,
430        )? {
431            return Ok(PkgBuildResult::Failed);
432        }
433
434        // Build
435        callback.stage(Stage::Build.as_str());
436        let build_log = self.logdir.join("build.log");
437        if !self.run_usergroup_if_needed(Stage::Build, &pkgdir, &build_log)? {
438            return Ok(PkgBuildResult::Failed);
439        }
440        if !self.run_make_stage(
441            Stage::Build,
442            &pkgdir,
443            &["all"],
444            self.build_run_as(),
445            true,
446        )? {
447            return Ok(PkgBuildResult::Failed);
448        }
449
450        // Install
451        callback.stage(Stage::Install.as_str());
452        let install_log = self.logdir.join("install.log");
453        if !self.run_usergroup_if_needed(
454            Stage::Install,
455            &pkgdir,
456            &install_log,
457        )? {
458            return Ok(PkgBuildResult::Failed);
459        }
460        if !self.run_make_stage(
461            Stage::Install,
462            &pkgdir,
463            &["stage-install"],
464            self.build_run_as(),
465            true,
466        )? {
467            return Ok(PkgBuildResult::Failed);
468        }
469
470        // Package
471        callback.stage(Stage::Package.as_str());
472        if !self.run_make_stage(
473            Stage::Package,
474            &pkgdir,
475            &["stage-package-create"],
476            RunAs::Root,
477            true,
478        )? {
479            return Ok(PkgBuildResult::Failed);
480        }
481
482        // Get the package file path
483        let pkgfile = self.get_make_var(&pkgdir, "STAGE_PKGFILE")?;
484
485        // Test package install (unless bootstrap package)
486        let is_bootstrap = self.pkginfo.bootstrap_pkg.as_deref() == Some("yes");
487        if !is_bootstrap {
488            if !self.pkg_add(&pkgfile)? {
489                return Ok(PkgBuildResult::Failed);
490            }
491
492            // Test package deinstall
493            callback.stage(Stage::Deinstall.as_str());
494            let _ = self.write_stage(Stage::Deinstall);
495            if !self.pkg_delete(pkgname)? {
496                return Ok(PkgBuildResult::Failed);
497            }
498        }
499
500        // Save package to packages directory
501        let packages =
502            self.config.packages().context("pkgsrc.packages not configured")?;
503        let packages_dir = packages.join("All");
504        fs::create_dir_all(&packages_dir)?;
505        let dest = packages_dir.join(
506            Path::new(&pkgfile)
507                .file_name()
508                .context("Invalid package file path")?,
509        );
510        // pkgfile is a path inside the sandbox; prepend sandbox path for host access
511        let host_pkgfile = if self.sandbox.enabled() {
512            self.sandbox
513                .path(self.sandbox_id)
514                .join(pkgfile.trim_start_matches('/'))
515        } else {
516            PathBuf::from(&pkgfile)
517        };
518        fs::copy(&host_pkgfile, &dest)?;
519
520        // Clean
521        callback.stage(Stage::Clean.as_str());
522        let _ = self.run_make_stage(
523            Stage::Clean,
524            &pkgdir,
525            &["clean"],
526            RunAs::Root,
527            false,
528        );
529
530        // Remove log directory on success
531        let _ = fs::remove_dir_all(&self.logdir);
532
533        Ok(PkgBuildResult::Success)
534    }
535
536    /// Determine how to run build commands.
537    fn build_run_as(&self) -> RunAs {
538        if self.build_user.is_some() { RunAs::User } else { RunAs::Root }
539    }
540
541    /// Write the current stage to a .stage file.
542    fn write_stage(&self, stage: Stage) -> anyhow::Result<()> {
543        let stage_file = self.logdir.join(".stage");
544        fs::write(&stage_file, stage.as_str())?;
545        Ok(())
546    }
547
548    /// Run a make stage with output logging.
549    fn run_make_stage(
550        &self,
551        stage: Stage,
552        pkgdir: &Path,
553        targets: &[&str],
554        run_as: RunAs,
555        include_make_flags: bool,
556    ) -> anyhow::Result<bool> {
557        // Write stage to .stage file
558        let _ = self.write_stage(stage);
559
560        let logfile = self.logdir.join(format!("{}.log", stage.as_str()));
561        let work_log = self.logdir.join("work.log");
562
563        let owned_args =
564            self.make_args(pkgdir, targets, include_make_flags, &work_log);
565
566        // Convert to slice of &str for the command
567        let args: Vec<&str> = owned_args.iter().map(|s| s.as_str()).collect();
568
569        debug!(stage = stage.as_str(), targets = ?targets, "Running make stage");
570
571        let status = self.run_command_logged(
572            self.config.make(),
573            &args,
574            run_as,
575            &logfile,
576        )?;
577
578        Ok(status.success())
579    }
580
581    /// Run a command with output logged to a file.
582    fn run_command_logged(
583        &self,
584        cmd: &Path,
585        args: &[&str],
586        run_as: RunAs,
587        logfile: &Path,
588    ) -> anyhow::Result<ExitStatus> {
589        self.run_command_logged_with_env(cmd, args, run_as, logfile, &[])
590    }
591
592    fn run_command_logged_with_env(
593        &self,
594        cmd: &Path,
595        args: &[&str],
596        run_as: RunAs,
597        logfile: &Path,
598        extra_envs: &[(&str, &str)],
599    ) -> anyhow::Result<ExitStatus> {
600        use std::io::{BufRead, BufReader, Write};
601
602        let mut log =
603            OpenOptions::new().create(true).append(true).open(logfile)?;
604
605        // Write command being executed to the log file
606        let _ = writeln!(log, "=> {:?} {:?}", cmd, args);
607        let _ = log.flush();
608
609        // Use tee-style pipe handling when output_tx is available for live view.
610        // Otherwise use direct file redirection.
611        if let Some(ref output_tx) = self.output_tx {
612            // Wrap command in shell to merge stdout/stderr with 2>&1, like the
613            // shell script's run_log function does.
614            let shell_cmd =
615                self.build_shell_command(cmd, args, run_as, extra_envs);
616            let mut child = self
617                .sandbox
618                .command(self.sandbox_id, Path::new("/bin/sh"))
619                .arg("-c")
620                .arg(&shell_cmd)
621                .stdout(Stdio::piped())
622                .stderr(Stdio::null())
623                .spawn()
624                .context("Failed to spawn shell command")?;
625
626            let stdout = child.stdout.take().unwrap();
627            let output_tx = output_tx.clone();
628            let sandbox_id = self.sandbox_id;
629
630            // Spawn thread to read from pipe and tee to file + output channel.
631            // Batch lines and throttle sends to reduce channel overhead.
632            let tee_handle = std::thread::spawn(move || {
633                let mut reader = BufReader::new(stdout);
634                let mut buf = Vec::new();
635                let mut batch = Vec::with_capacity(50);
636                let mut last_send = Instant::now();
637                let send_interval = Duration::from_millis(100);
638
639                loop {
640                    buf.clear();
641                    match reader.read_until(b'\n', &mut buf) {
642                        Ok(0) => break,
643                        Ok(_) => {}
644                        Err(_) => break,
645                    };
646                    // Write raw bytes to log file to preserve original output
647                    let _ = log.write_all(&buf);
648                    // Convert to lossy UTF-8 for live view
649                    let line = String::from_utf8_lossy(&buf);
650                    let line = line.trim_end_matches('\n').to_string();
651                    batch.push(line);
652
653                    // Send batch if interval elapsed or batch is large
654                    if last_send.elapsed() >= send_interval || batch.len() >= 50
655                    {
656                        let _ = output_tx.send(ChannelCommand::OutputLines(
657                            sandbox_id,
658                            std::mem::take(&mut batch),
659                        ));
660                        last_send = Instant::now();
661                    }
662                }
663
664                // Send remaining lines
665                if !batch.is_empty() {
666                    let _ = output_tx
667                        .send(ChannelCommand::OutputLines(sandbox_id, batch));
668                }
669            });
670
671            // Wait for command to exit
672            let status = child.wait()?;
673
674            // Reader thread will exit when pipe closes (process exits)
675            let _ = tee_handle.join();
676
677            trace!(cmd = ?cmd, status = ?status, "Command completed");
678            Ok(status)
679        } else {
680            let status =
681                self.spawn_command_to_file(cmd, args, run_as, extra_envs, log)?;
682            trace!(cmd = ?cmd, status = ?status, "Command completed");
683            Ok(status)
684        }
685    }
686
687    /// Spawn a command with stdout/stderr redirected to a file.
688    fn spawn_command_to_file(
689        &self,
690        cmd: &Path,
691        args: &[&str],
692        run_as: RunAs,
693        extra_envs: &[(&str, &str)],
694        log: File,
695    ) -> anyhow::Result<ExitStatus> {
696        // Clone file handle for stderr (stdout and stderr both go to same file)
697        let log_err = log.try_clone()?;
698
699        match run_as {
700            RunAs::Root => {
701                let mut command = self.sandbox.command(self.sandbox_id, cmd);
702                command.args(args);
703                self.apply_envs(&mut command, extra_envs);
704                command
705                    .stdout(Stdio::from(log))
706                    .stderr(Stdio::from(log_err))
707                    .status()
708                    .with_context(|| format!("Failed to run {}", cmd.display()))
709            }
710            RunAs::User => {
711                let user = self.build_user.as_ref().unwrap();
712                let mut parts = Vec::with_capacity(args.len() + 1);
713                parts.push(cmd.display().to_string());
714                parts.extend(args.iter().map(|arg| arg.to_string()));
715                let inner_cmd = parts
716                    .iter()
717                    .map(|part| Self::shell_escape(part))
718                    .collect::<Vec<_>>()
719                    .join(" ");
720                let mut command =
721                    self.sandbox.command(self.sandbox_id, Path::new("su"));
722                command.arg(user).arg("-c").arg(&inner_cmd);
723                self.apply_envs(&mut command, extra_envs);
724                command
725                    .stdout(Stdio::from(log))
726                    .stderr(Stdio::from(log_err))
727                    .status()
728                    .context("Failed to run su command")
729            }
730        }
731    }
732
733    /// Get a make variable value.
734    fn get_make_var(
735        &self,
736        pkgdir: &Path,
737        varname: &str,
738    ) -> anyhow::Result<String> {
739        let mut cmd = self.sandbox.command(self.sandbox_id, self.config.make());
740        self.apply_envs(&mut cmd, &[]);
741
742        let work_log = self.logdir.join("work.log");
743        let make_args = self.make_args(
744            pkgdir,
745            &["show-var", &format!("VARNAME={}", varname)],
746            true,
747            &work_log,
748        );
749
750        let bob_log = File::options()
751            .create(true)
752            .append(true)
753            .open(self.logdir.join("bob.log"))?;
754        let output =
755            cmd.args(&make_args).stderr(Stdio::from(bob_log)).output()?;
756
757        if !output.status.success() {
758            bail!("Failed to get make variable {}", varname);
759        }
760
761        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
762    }
763
764    /// Install package dependencies.
765    fn install_dependencies(&self) -> anyhow::Result<bool> {
766        let deps: Vec<String> =
767            self.pkginfo.depends.iter().map(|d| d.to_string()).collect();
768
769        let packages =
770            self.config.packages().context("pkgsrc.packages not configured")?;
771        let pkg_path = packages.join("All");
772        let logfile = self.logdir.join("depends.log");
773
774        let mut args = vec![];
775        for dep in &deps {
776            args.push(dep.as_str());
777        }
778
779        let status = self.run_pkg_add_with_path(&args, &pkg_path, &logfile)?;
780        Ok(status.success())
781    }
782
783    /// Run pkg_add with PKG_PATH set.
784    fn run_pkg_add_with_path(
785        &self,
786        packages: &[&str],
787        pkg_path: &Path,
788        logfile: &Path,
789    ) -> anyhow::Result<ExitStatus> {
790        let pkgtools =
791            self.config.pkgtools().context("pkgsrc.pkgtools not configured")?;
792        let pkg_add = pkgtools.join("pkg_add");
793        let pkg_path_value = pkg_path.to_string_lossy().to_string();
794        let extra_envs = [("PKG_PATH", pkg_path_value.as_str())];
795
796        self.run_command_logged_with_env(
797            &pkg_add,
798            packages,
799            RunAs::Root,
800            logfile,
801            &extra_envs,
802        )
803    }
804
805    /// Install a package file.
806    fn pkg_add(&self, pkgfile: &str) -> anyhow::Result<bool> {
807        let pkgtools =
808            self.config.pkgtools().context("pkgsrc.pkgtools not configured")?;
809        let pkg_add = pkgtools.join("pkg_add");
810        let logfile = self.logdir.join("package.log");
811
812        let status = self.run_command_logged(
813            &pkg_add,
814            &[pkgfile],
815            RunAs::Root,
816            &logfile,
817        )?;
818
819        Ok(status.success())
820    }
821
822    /// Delete an installed package.
823    fn pkg_delete(&self, pkgname: &str) -> anyhow::Result<bool> {
824        let pkgtools =
825            self.config.pkgtools().context("pkgsrc.pkgtools not configured")?;
826        let pkg_delete = pkgtools.join("pkg_delete");
827        let logfile = self.logdir.join("deinstall.log");
828
829        let status = self.run_command_logged(
830            &pkg_delete,
831            &[pkgname],
832            RunAs::Root,
833            &logfile,
834        )?;
835
836        Ok(status.success())
837    }
838
839    /// Run create-usergroup if needed based on usergroup_phase.
840    fn run_usergroup_if_needed(
841        &self,
842        stage: Stage,
843        pkgdir: &Path,
844        logfile: &Path,
845    ) -> anyhow::Result<bool> {
846        let usergroup_phase =
847            self.pkginfo.usergroup_phase.as_deref().unwrap_or("");
848
849        let should_run = match stage {
850            Stage::Configure => usergroup_phase.ends_with("configure"),
851            Stage::Build => usergroup_phase.ends_with("build"),
852            Stage::Install => usergroup_phase == "pre-install",
853            _ => false,
854        };
855
856        if !should_run {
857            return Ok(true);
858        }
859
860        let mut args = vec!["-C", pkgdir.to_str().unwrap(), "create-usergroup"];
861        if stage == Stage::Configure {
862            args.push("clean");
863        }
864
865        let status = self.run_command_logged(
866            self.config.make(),
867            &args,
868            RunAs::Root,
869            logfile,
870        )?;
871        Ok(status.success())
872    }
873
874    fn make_args(
875        &self,
876        pkgdir: &Path,
877        targets: &[&str],
878        include_make_flags: bool,
879        work_log: &Path,
880    ) -> Vec<String> {
881        let mut owned_args: Vec<String> =
882            vec!["-C".to_string(), pkgdir.to_str().unwrap().to_string()];
883        owned_args.extend(targets.iter().map(|s| s.to_string()));
884
885        if include_make_flags {
886            owned_args.push("BATCH=1".to_string());
887            owned_args.push("DEPENDS_TARGET=/nonexistent".to_string());
888
889            if let Some(ref multi_version) = self.pkginfo.multi_version {
890                for flag in multi_version {
891                    owned_args.push(flag.clone());
892                }
893            }
894
895            owned_args.push(format!("WRKLOG={}", work_log.display()));
896        }
897
898        owned_args
899    }
900
901    fn apply_envs(&self, cmd: &mut Command, extra_envs: &[(&str, &str)]) {
902        for (key, value) in &self.envs {
903            cmd.env(key, value);
904        }
905        for (key, value) in extra_envs {
906            cmd.env(key, value);
907        }
908    }
909
910    fn shell_escape(value: &str) -> String {
911        if value.is_empty() {
912            return "''".to_string();
913        }
914        if value
915            .chars()
916            .all(|c| c.is_ascii_alphanumeric() || "-_.,/:=+@".contains(c))
917        {
918            return value.to_string();
919        }
920        let escaped = value.replace('\'', "'\\''");
921        format!("'{}'", escaped)
922    }
923
924    /// Build a shell command string with environment, run_as handling, and 2>&1.
925    fn build_shell_command(
926        &self,
927        cmd: &Path,
928        args: &[&str],
929        run_as: RunAs,
930        extra_envs: &[(&str, &str)],
931    ) -> String {
932        let mut parts = Vec::new();
933
934        // Add environment variables
935        for (key, value) in &self.envs {
936            parts.push(format!("{}={}", key, Self::shell_escape(value)));
937        }
938        for (key, value) in extra_envs {
939            parts.push(format!("{}={}", key, Self::shell_escape(value)));
940        }
941
942        // Build the actual command
943        let cmd_str = Self::shell_escape(&cmd.to_string_lossy());
944        let args_str: Vec<String> =
945            args.iter().map(|a| Self::shell_escape(a)).collect();
946
947        match run_as {
948            RunAs::Root => {
949                parts.push(cmd_str);
950                parts.extend(args_str);
951            }
952            RunAs::User => {
953                let user = self.build_user.as_ref().unwrap();
954                let inner_cmd = std::iter::once(cmd_str)
955                    .chain(args_str)
956                    .collect::<Vec<_>>()
957                    .join(" ");
958                parts.push("su".to_string());
959                parts.push(Self::shell_escape(user));
960                parts.push("-c".to_string());
961                parts.push(Self::shell_escape(&inner_cmd));
962            }
963        }
964
965        // Merge stdout/stderr
966        parts.push("2>&1".to_string());
967        parts.join(" ")
968    }
969}
970
971/// Callback adapter that sends build updates through a channel.
972struct ChannelCallback<'a> {
973    sandbox_id: usize,
974    status_tx: &'a Sender<ChannelCommand>,
975}
976
977impl<'a> ChannelCallback<'a> {
978    fn new(sandbox_id: usize, status_tx: &'a Sender<ChannelCommand>) -> Self {
979        Self { sandbox_id, status_tx }
980    }
981}
982
983impl<'a> BuildCallback for ChannelCallback<'a> {
984    fn stage(&mut self, stage: &str) {
985        let _ = self.status_tx.send(ChannelCommand::StageUpdate(
986            self.sandbox_id,
987            Some(stage.to_string()),
988        ));
989    }
990}
991
992/// Outcome of a package build attempt.
993///
994/// Used in [`BuildResult`] to indicate whether the build succeeded, failed,
995/// or was skipped.
996#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
997pub enum BuildOutcome {
998    /// Package built and packaged successfully.
999    Success,
1000    /// Package build failed.
1001    ///
1002    /// The string contains the failure reason (e.g., "Failed in build phase").
1003    Failed(String),
1004    /// Package did not need to be built - we already have a binary package
1005    /// for this revision.
1006    UpToDate,
1007    /// Package is marked with PKG_SKIP_REASON or PKG_FAIL_REASON so cannot
1008    /// be built.
1009    ///
1010    /// The string contains the skip/fail reason.
1011    PreFailed(String),
1012    /// Package depends on a different package that has Failed.
1013    ///
1014    /// The string contains the name of the failed dependency.
1015    IndirectFailed(String),
1016    /// Package depends on a different package that has PreFailed.
1017    ///
1018    /// The string contains the name of the pre-failed dependency.
1019    IndirectPreFailed(String),
1020}
1021
1022/// Result of building a single package.
1023///
1024/// Contains the outcome, timing, and log location for a package build.
1025#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1026pub struct BuildResult {
1027    /// Package name with version (e.g., `mutt-2.2.12`).
1028    pub pkgname: PkgName,
1029    /// Package path in pkgsrc (e.g., `mail/mutt`).
1030    pub pkgpath: Option<PkgPath>,
1031    /// Build outcome (success, failure, or skipped).
1032    pub outcome: BuildOutcome,
1033    /// Time spent building this package.
1034    pub duration: Duration,
1035    /// Path to build logs directory, if available.
1036    ///
1037    /// For failed builds, this contains `pre-clean.log`, `build.log`, etc.
1038    /// Successful builds clean up their log directories.
1039    pub log_dir: Option<PathBuf>,
1040}
1041
1042/// Summary of an entire build run.
1043///
1044/// Contains timing information and results for all packages.
1045///
1046/// # Example
1047///
1048/// ```no_run
1049/// # use bob::BuildSummary;
1050/// # fn example(summary: &BuildSummary) {
1051/// println!("Succeeded: {}", summary.success_count());
1052/// println!("Failed: {}", summary.failed_count());
1053/// println!("Up-to-date: {}", summary.up_to_date_count());
1054/// println!("Duration: {:?}", summary.duration);
1055///
1056/// for result in summary.failed() {
1057///     println!("  {} failed", result.pkgname.pkgname());
1058/// }
1059/// # }
1060/// ```
1061#[derive(Clone, Debug)]
1062pub struct BuildSummary {
1063    /// Total duration of the build run.
1064    pub duration: Duration,
1065    /// Results for each package.
1066    pub results: Vec<BuildResult>,
1067    /// Packages that failed to scan (bmake pbulk-index failed).
1068    pub scan_failed: Vec<ScanFailure>,
1069}
1070
1071impl BuildSummary {
1072    /// Count of successfully built packages.
1073    pub fn success_count(&self) -> usize {
1074        self.results
1075            .iter()
1076            .filter(|r| matches!(r.outcome, BuildOutcome::Success))
1077            .count()
1078    }
1079
1080    /// Count of failed packages (direct build failures only).
1081    pub fn failed_count(&self) -> usize {
1082        self.results
1083            .iter()
1084            .filter(|r| matches!(r.outcome, BuildOutcome::Failed(_)))
1085            .count()
1086    }
1087
1088    /// Count of up-to-date packages (already have binary package).
1089    pub fn up_to_date_count(&self) -> usize {
1090        self.results
1091            .iter()
1092            .filter(|r| matches!(r.outcome, BuildOutcome::UpToDate))
1093            .count()
1094    }
1095
1096    /// Count of pre-failed packages (PKG_SKIP_REASON/PKG_FAIL_REASON).
1097    pub fn prefailed_count(&self) -> usize {
1098        self.results
1099            .iter()
1100            .filter(|r| matches!(r.outcome, BuildOutcome::PreFailed(_)))
1101            .count()
1102    }
1103
1104    /// Count of indirect failed packages (depend on Failed).
1105    pub fn indirect_failed_count(&self) -> usize {
1106        self.results
1107            .iter()
1108            .filter(|r| matches!(r.outcome, BuildOutcome::IndirectFailed(_)))
1109            .count()
1110    }
1111
1112    /// Count of indirect pre-failed packages (depend on PreFailed).
1113    pub fn indirect_prefailed_count(&self) -> usize {
1114        self.results
1115            .iter()
1116            .filter(|r| matches!(r.outcome, BuildOutcome::IndirectPreFailed(_)))
1117            .count()
1118    }
1119
1120    /// Count of packages that failed to scan.
1121    pub fn scan_failed_count(&self) -> usize {
1122        self.scan_failed.len()
1123    }
1124
1125    /// Get all failed results (direct build failures only).
1126    pub fn failed(&self) -> Vec<&BuildResult> {
1127        self.results
1128            .iter()
1129            .filter(|r| matches!(r.outcome, BuildOutcome::Failed(_)))
1130            .collect()
1131    }
1132
1133    /// Get all successful results.
1134    pub fn succeeded(&self) -> Vec<&BuildResult> {
1135        self.results
1136            .iter()
1137            .filter(|r| matches!(r.outcome, BuildOutcome::Success))
1138            .collect()
1139    }
1140
1141    /// Get all up-to-date results.
1142    pub fn up_to_date(&self) -> Vec<&BuildResult> {
1143        self.results
1144            .iter()
1145            .filter(|r| matches!(r.outcome, BuildOutcome::UpToDate))
1146            .collect()
1147    }
1148
1149    /// Get all pre-failed results.
1150    pub fn prefailed(&self) -> Vec<&BuildResult> {
1151        self.results
1152            .iter()
1153            .filter(|r| matches!(r.outcome, BuildOutcome::PreFailed(_)))
1154            .collect()
1155    }
1156
1157    /// Get all indirect failed results.
1158    pub fn indirect_failed(&self) -> Vec<&BuildResult> {
1159        self.results
1160            .iter()
1161            .filter(|r| matches!(r.outcome, BuildOutcome::IndirectFailed(_)))
1162            .collect()
1163    }
1164
1165    /// Get all indirect pre-failed results.
1166    pub fn indirect_prefailed(&self) -> Vec<&BuildResult> {
1167        self.results
1168            .iter()
1169            .filter(|r| matches!(r.outcome, BuildOutcome::IndirectPreFailed(_)))
1170            .collect()
1171    }
1172}
1173
1174/// Options that control build behavior.
1175#[derive(Clone, Debug, Default)]
1176pub struct BuildOptions {
1177    /// Force rebuild even if package is up-to-date.
1178    pub force_rebuild: bool,
1179}
1180
1181#[derive(Debug, Default)]
1182pub struct Build {
1183    /// Parsed [`Config`].
1184    config: Config,
1185    /// [`Sandbox`] configuration.
1186    sandbox: Sandbox,
1187    /// List of packages to build, as input from Scan::resolve.
1188    scanpkgs: IndexMap<PkgName, ResolvedIndex>,
1189    /// Cached build results from previous run.
1190    cached: IndexMap<PkgName, BuildResult>,
1191    /// Build options.
1192    options: BuildOptions,
1193}
1194
1195#[derive(Debug)]
1196struct PackageBuild {
1197    id: usize,
1198    config: Config,
1199    pkginfo: ResolvedIndex,
1200    sandbox: Sandbox,
1201    options: BuildOptions,
1202}
1203
1204/// Helper for querying bmake variables with the correct environment.
1205struct MakeQuery<'a> {
1206    config: &'a Config,
1207    sandbox: &'a Sandbox,
1208    sandbox_id: usize,
1209    pkgpath: &'a PkgPath,
1210    env: &'a HashMap<String, String>,
1211}
1212
1213impl<'a> MakeQuery<'a> {
1214    fn new(
1215        config: &'a Config,
1216        sandbox: &'a Sandbox,
1217        sandbox_id: usize,
1218        pkgpath: &'a PkgPath,
1219        env: &'a HashMap<String, String>,
1220    ) -> Self {
1221        Self { config, sandbox, sandbox_id, pkgpath, env }
1222    }
1223
1224    /// Query a bmake variable value.
1225    fn var(&self, name: &str) -> Option<String> {
1226        let pkgdir = self.config.pkgsrc().join(self.pkgpath.as_path());
1227
1228        let mut cmd = self.sandbox.command(self.sandbox_id, self.config.make());
1229        cmd.arg("-C")
1230            .arg(&pkgdir)
1231            .arg("show-var")
1232            .arg(format!("VARNAME={}", name));
1233
1234        // Pass env vars that may affect the variable value
1235        for (key, value) in self.env {
1236            cmd.env(key, value);
1237        }
1238
1239        cmd.stderr(Stdio::null());
1240
1241        let output = cmd.output().ok()?;
1242
1243        if !output.status.success() {
1244            return None;
1245        }
1246
1247        let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
1248
1249        if value.is_empty() { None } else { Some(value) }
1250    }
1251
1252    /// Query a bmake variable and return as PathBuf.
1253    fn var_path(&self, name: &str) -> Option<PathBuf> {
1254        self.var(name).map(PathBuf::from)
1255    }
1256
1257    /// Get the WRKDIR for this package.
1258    fn wrkdir(&self) -> Option<PathBuf> {
1259        self.var_path("WRKDIR")
1260    }
1261
1262    /// Get the WRKSRC for this package.
1263    #[allow(dead_code)]
1264    fn wrksrc(&self) -> Option<PathBuf> {
1265        self.var_path("WRKSRC")
1266    }
1267
1268    /// Get the DESTDIR for this package.
1269    #[allow(dead_code)]
1270    fn destdir(&self) -> Option<PathBuf> {
1271        self.var_path("DESTDIR")
1272    }
1273
1274    /// Get the PREFIX for this package.
1275    #[allow(dead_code)]
1276    fn prefix(&self) -> Option<PathBuf> {
1277        self.var_path("PREFIX")
1278    }
1279
1280    /// Resolve a path to its actual location on the host filesystem.
1281    /// If sandboxed, prepends the sandbox root path.
1282    fn resolve_path(&self, path: &Path) -> PathBuf {
1283        if self.sandbox.enabled() {
1284            self.sandbox
1285                .path(self.sandbox_id)
1286                .join(path.strip_prefix("/").unwrap_or(path))
1287        } else {
1288            path.to_path_buf()
1289        }
1290    }
1291}
1292
1293/// Result of a single package build attempt.
1294#[derive(Debug)]
1295enum PackageBuildResult {
1296    /// Build succeeded
1297    Success,
1298    /// Build failed
1299    Failed,
1300    /// Package was up-to-date, skipped
1301    Skipped,
1302}
1303
1304impl PackageBuild {
1305    fn build(
1306        &self,
1307        status_tx: &Sender<ChannelCommand>,
1308    ) -> anyhow::Result<PackageBuildResult> {
1309        let pkgname = self.pkginfo.pkgname.pkgname();
1310        info!(pkgname = %pkgname,
1311            sandbox_id = self.id,
1312            "Starting package build"
1313        );
1314
1315        let Some(pkgpath) = &self.pkginfo.pkg_location else {
1316            error!(pkgname = %pkgname, "Could not get PKGPATH for package");
1317            bail!("Could not get PKGPATH for {}", pkgname);
1318        };
1319
1320        let logdir = self.config.logdir();
1321
1322        // Get env vars from Lua config for wrkdir saving and build environment
1323        let pkg_env = match self.config.get_pkg_env(&self.pkginfo) {
1324            Ok(env) => env,
1325            Err(e) => {
1326                error!(pkgname = %pkgname, error = %e, "Failed to get env from Lua config");
1327                HashMap::new()
1328            }
1329        };
1330
1331        let mut envs = self.config.script_env();
1332        for (key, value) in &pkg_env {
1333            envs.push((key.clone(), value.clone()));
1334        }
1335
1336        let patterns = self.config.save_wrkdir_patterns();
1337
1338        // Run pre-build script if defined (always runs)
1339        if let Some(pre_build) = self.config.script("pre-build") {
1340            debug!(pkgname = %pkgname, "Running pre-build script");
1341            let child = self.sandbox.execute(
1342                self.id,
1343                pre_build,
1344                envs.clone(),
1345                None,
1346                None,
1347            )?;
1348            let output = child
1349                .wait_with_output()
1350                .context("Failed to wait for pre-build")?;
1351            if !output.status.success() {
1352                warn!(pkgname = %pkgname, exit_code = ?output.status.code(), "pre-build script failed");
1353            }
1354        }
1355
1356        // Run the build using PkgBuilder
1357        let builder = PkgBuilder::new(
1358            &self.config,
1359            &self.sandbox,
1360            self.id,
1361            &self.pkginfo,
1362            envs.clone(),
1363            Some(status_tx.clone()),
1364            &self.options,
1365        );
1366
1367        let mut callback = ChannelCallback::new(self.id, status_tx);
1368        let result = builder.build(&mut callback);
1369
1370        // Clear stage display
1371        let _ = status_tx.send(ChannelCommand::StageUpdate(self.id, None));
1372
1373        let result = match &result {
1374            Ok(PkgBuildResult::Success) => {
1375                info!(pkgname = %pkgname, "package build completed successfully");
1376                PackageBuildResult::Success
1377            }
1378            Ok(PkgBuildResult::Skipped) => {
1379                info!(pkgname = %pkgname, "package build skipped (up-to-date)");
1380                PackageBuildResult::Skipped
1381            }
1382            Ok(PkgBuildResult::Failed) => {
1383                error!(pkgname = %pkgname, "package build failed");
1384                // Show cleanup stage to user
1385                let _ = status_tx.send(ChannelCommand::StageUpdate(
1386                    self.id,
1387                    Some("cleanup".to_string()),
1388                ));
1389                // Kill any orphaned processes in the sandbox before cleanup.
1390                // Failed builds may leave processes running that would block
1391                // subsequent commands like bmake show-var or bmake clean.
1392                debug!(pkgname = %pkgname, "Calling kill_processes_by_id");
1393                let kill_start = Instant::now();
1394                self.sandbox.kill_processes_by_id(self.id);
1395                debug!(pkgname = %pkgname, elapsed_ms = kill_start.elapsed().as_millis(), "kill_processes_by_id completed");
1396                // Save wrkdir files matching configured patterns, then clean up
1397                if !patterns.is_empty() {
1398                    debug!(pkgname = %pkgname, "Calling save_wrkdir_files");
1399                    let save_start = Instant::now();
1400                    self.save_wrkdir_files(
1401                        pkgname, pkgpath, logdir, patterns, &pkg_env,
1402                    );
1403                    debug!(pkgname = %pkgname, elapsed_ms = save_start.elapsed().as_millis(), "save_wrkdir_files completed");
1404                    debug!(pkgname = %pkgname, "Calling run_clean");
1405                    let clean_start = Instant::now();
1406                    self.run_clean(pkgpath, &envs);
1407                    debug!(pkgname = %pkgname, elapsed_ms = clean_start.elapsed().as_millis(), "run_clean completed");
1408                } else {
1409                    debug!(pkgname = %pkgname, "Calling run_clean (no patterns)");
1410                    let clean_start = Instant::now();
1411                    self.run_clean(pkgpath, &envs);
1412                    debug!(pkgname = %pkgname, elapsed_ms = clean_start.elapsed().as_millis(), "run_clean completed");
1413                }
1414                PackageBuildResult::Failed
1415            }
1416            Err(e) => {
1417                error!(pkgname = %pkgname, error = %e, "package build error");
1418                // Show cleanup stage to user
1419                let _ = status_tx.send(ChannelCommand::StageUpdate(
1420                    self.id,
1421                    Some("cleanup".to_string()),
1422                ));
1423                // Kill any orphaned processes in the sandbox before cleanup.
1424                // Failed builds may leave processes running that would block
1425                // subsequent commands like bmake show-var or bmake clean.
1426                debug!(pkgname = %pkgname, "Calling kill_processes_by_id");
1427                let kill_start = Instant::now();
1428                self.sandbox.kill_processes_by_id(self.id);
1429                debug!(pkgname = %pkgname, elapsed_ms = kill_start.elapsed().as_millis(), "kill_processes_by_id completed");
1430                // Save wrkdir files matching configured patterns, then clean up
1431                if !patterns.is_empty() {
1432                    debug!(pkgname = %pkgname, "Calling save_wrkdir_files");
1433                    let save_start = Instant::now();
1434                    self.save_wrkdir_files(
1435                        pkgname, pkgpath, logdir, patterns, &pkg_env,
1436                    );
1437                    debug!(pkgname = %pkgname, elapsed_ms = save_start.elapsed().as_millis(), "save_wrkdir_files completed");
1438                    debug!(pkgname = %pkgname, "Calling run_clean");
1439                    let clean_start = Instant::now();
1440                    self.run_clean(pkgpath, &envs);
1441                    debug!(pkgname = %pkgname, elapsed_ms = clean_start.elapsed().as_millis(), "run_clean completed");
1442                } else {
1443                    debug!(pkgname = %pkgname, "Calling run_clean (no patterns)");
1444                    let clean_start = Instant::now();
1445                    self.run_clean(pkgpath, &envs);
1446                    debug!(pkgname = %pkgname, elapsed_ms = clean_start.elapsed().as_millis(), "run_clean completed");
1447                }
1448                PackageBuildResult::Failed
1449            }
1450        };
1451
1452        // Run post-build script if defined (always runs regardless of result)
1453        if let Some(post_build) = self.config.script("post-build") {
1454            debug!(pkgname = %pkgname, script = %post_build.display(), "Running post-build script");
1455            match self.sandbox.execute(self.id, post_build, envs, None, None) {
1456                Ok(child) => {
1457                    debug!(pkgname = %pkgname, pid = ?child.id(), "post-build spawned, waiting");
1458                    match child.wait_with_output() {
1459                        Ok(output) => {
1460                            debug!(pkgname = %pkgname, exit_code = ?output.status.code(), "post-build completed");
1461                            if !output.status.success() {
1462                                warn!(pkgname = %pkgname, exit_code = ?output.status.code(), "post-build script failed");
1463                            }
1464                        }
1465                        Err(e) => {
1466                            warn!(pkgname = %pkgname, error = %e, "Failed to wait for post-build");
1467                        }
1468                    }
1469                }
1470                Err(e) => {
1471                    warn!(pkgname = %pkgname, error = %e, "Failed to spawn post-build script");
1472                }
1473            }
1474        }
1475
1476        Ok(result)
1477    }
1478
1479    /// Save files matching patterns from WRKDIR to logdir on build failure.
1480    fn save_wrkdir_files(
1481        &self,
1482        pkgname: &str,
1483        pkgpath: &PkgPath,
1484        logdir: &Path,
1485        patterns: &[String],
1486        pkg_env: &HashMap<String, String>,
1487    ) {
1488        let make = MakeQuery::new(
1489            &self.config,
1490            &self.sandbox,
1491            self.id,
1492            pkgpath,
1493            pkg_env,
1494        );
1495
1496        // Get WRKDIR
1497        let wrkdir = match make.wrkdir() {
1498            Some(w) => w,
1499            None => {
1500                debug!(pkgname = %pkgname, "Could not determine WRKDIR, skipping file save");
1501                return;
1502            }
1503        };
1504
1505        // Resolve to actual filesystem path
1506        let wrkdir_path = make.resolve_path(&wrkdir);
1507
1508        if !wrkdir_path.exists() {
1509            debug!(pkgname = %pkgname,
1510                wrkdir = %wrkdir_path.display(),
1511                "WRKDIR does not exist, skipping file save"
1512            );
1513            return;
1514        }
1515
1516        let save_dir = logdir.join(pkgname).join("wrkdir-files");
1517        if let Err(e) = fs::create_dir_all(&save_dir) {
1518            warn!(pkgname = %pkgname,
1519                error = %e,
1520                "Failed to create wrkdir-files directory"
1521            );
1522            return;
1523        }
1524
1525        // Compile glob patterns
1526        let compiled_patterns: Vec<Pattern> = patterns
1527            .iter()
1528            .filter_map(|p| {
1529                Pattern::new(p).ok().or_else(|| {
1530                    warn!(pattern = %p, "Invalid glob pattern");
1531                    None
1532                })
1533            })
1534            .collect();
1535
1536        if compiled_patterns.is_empty() {
1537            return;
1538        }
1539
1540        // Walk the wrkdir and find matching files
1541        let mut saved_count = 0;
1542        if let Err(e) = walk_and_save(
1543            &wrkdir_path,
1544            &wrkdir_path,
1545            &save_dir,
1546            &compiled_patterns,
1547            &mut saved_count,
1548        ) {
1549            warn!(pkgname = %pkgname,
1550                error = %e,
1551                "Error while saving wrkdir files"
1552            );
1553        }
1554
1555        if saved_count > 0 {
1556            info!(pkgname = %pkgname,
1557                count = saved_count,
1558                dest = %save_dir.display(),
1559                "Saved wrkdir files"
1560            );
1561        }
1562    }
1563
1564    /// Run bmake clean for a package.
1565    fn run_clean(&self, pkgpath: &PkgPath, envs: &[(String, String)]) {
1566        let pkgdir = self.config.pkgsrc().join(pkgpath.as_path());
1567
1568        let mut cmd = self.sandbox.command(self.id, self.config.make());
1569        cmd.arg("-C").arg(&pkgdir).arg("clean");
1570        for (key, value) in envs {
1571            cmd.env(key, value);
1572        }
1573        let result = cmd
1574            .stdout(std::process::Stdio::null())
1575            .stderr(std::process::Stdio::null())
1576            .status();
1577
1578        if let Err(e) = result {
1579            debug!(error = %e, "Failed to run bmake clean");
1580        }
1581    }
1582}
1583
1584/// Recursively walk a directory and save files matching patterns.
1585fn walk_and_save(
1586    base: &Path,
1587    current: &Path,
1588    save_dir: &Path,
1589    patterns: &[Pattern],
1590    saved_count: &mut usize,
1591) -> std::io::Result<()> {
1592    if !current.is_dir() {
1593        return Ok(());
1594    }
1595
1596    for entry in fs::read_dir(current)? {
1597        let entry = entry?;
1598        let path = entry.path();
1599
1600        if path.is_dir() {
1601            walk_and_save(base, &path, save_dir, patterns, saved_count)?;
1602        } else if path.is_file() {
1603            // Get relative path from base
1604            let rel_path = path.strip_prefix(base).unwrap_or(&path);
1605            let rel_str = rel_path.to_string_lossy();
1606
1607            // Check if any pattern matches
1608            for pattern in patterns {
1609                if pattern.matches(&rel_str)
1610                    || pattern.matches(
1611                        path.file_name()
1612                            .unwrap_or_default()
1613                            .to_string_lossy()
1614                            .as_ref(),
1615                    )
1616                {
1617                    // Create destination directory
1618                    let dest_path = save_dir.join(rel_path);
1619                    if let Some(parent) = dest_path.parent() {
1620                        fs::create_dir_all(parent)?;
1621                    }
1622
1623                    // Copy the file
1624                    if let Err(e) = fs::copy(&path, &dest_path) {
1625                        warn!(src = %path.display(),
1626                            dest = %dest_path.display(),
1627                            error = %e,
1628                            "Failed to copy file"
1629                        );
1630                    } else {
1631                        debug!(src = %path.display(),
1632                            dest = %dest_path.display(),
1633                            "Saved wrkdir file"
1634                        );
1635                        *saved_count += 1;
1636                    }
1637                    break; // Don't copy same file multiple times
1638                }
1639            }
1640        }
1641    }
1642
1643    Ok(())
1644}
1645
1646/**
1647 * Commands sent between the manager and clients.
1648 */
1649#[derive(Debug)]
1650enum ChannelCommand {
1651    /**
1652     * Client (with specified identifier) indicating they are ready for work.
1653     */
1654    ClientReady(usize),
1655    /**
1656     * Manager has no work available at the moment, try again later.
1657     */
1658    ComeBackLater,
1659    /**
1660     * Manager directing a client to build a specific package.
1661     */
1662    JobData(Box<PackageBuild>),
1663    /**
1664     * Client returning a successful package build with duration.
1665     */
1666    JobSuccess(PkgName, Duration),
1667    /**
1668     * Client returning a failed package build with duration.
1669     */
1670    JobFailed(PkgName, Duration),
1671    /**
1672     * Client returning a skipped package (up-to-date).
1673     */
1674    JobSkipped(PkgName),
1675    /**
1676     * Client returning an error during the package build.
1677     */
1678    JobError((PkgName, Duration, anyhow::Error)),
1679    /**
1680     * Manager directing a client to quit.
1681     */
1682    Quit,
1683    /**
1684     * Shutdown signal - workers should stop immediately.
1685     */
1686    Shutdown,
1687    /**
1688     * Client reporting a stage update for a build.
1689     */
1690    StageUpdate(usize, Option<String>),
1691    /**
1692     * Client reporting output lines from a build.
1693     */
1694    OutputLines(usize, Vec<String>),
1695}
1696
1697/**
1698 * Return the current build job status.
1699 */
1700#[derive(Debug)]
1701enum BuildStatus {
1702    /**
1703     * The next package ordered by priority is available for building.
1704     */
1705    Available(PkgName),
1706    /**
1707     * No packages are currently available for building, i.e. all remaining
1708     * packages have at least one dependency that is still unavailable.
1709     */
1710    NoneAvailable,
1711    /**
1712     * All package builds have been completed.
1713     */
1714    Done,
1715}
1716
1717#[derive(Clone, Debug)]
1718struct BuildJobs {
1719    scanpkgs: IndexMap<PkgName, ResolvedIndex>,
1720    incoming: HashMap<PkgName, HashSet<PkgName>>,
1721    /// Reverse dependency map: package -> packages that depend on it.
1722    /// Precomputed for O(1) lookup in mark_failure instead of O(n) scan.
1723    reverse_deps: HashMap<PkgName, HashSet<PkgName>>,
1724    /// Effective weight: package's PBULK_WEIGHT + sum of weights of all
1725    /// transitive dependents. Precomputed for efficient build ordering.
1726    effective_weights: HashMap<PkgName, usize>,
1727    running: HashSet<PkgName>,
1728    done: HashSet<PkgName>,
1729    failed: HashSet<PkgName>,
1730    results: Vec<BuildResult>,
1731    logdir: PathBuf,
1732    /// Number of packages loaded from cache.
1733    #[allow(dead_code)]
1734    cached_count: usize,
1735}
1736
1737impl BuildJobs {
1738    /**
1739     * Mark a package as successful and remove it from pending dependencies.
1740     */
1741    fn mark_success(&mut self, pkgname: &PkgName, duration: Duration) {
1742        self.mark_done(pkgname, BuildOutcome::Success, duration);
1743    }
1744
1745    fn mark_up_to_date(&mut self, pkgname: &PkgName) {
1746        self.mark_done(pkgname, BuildOutcome::UpToDate, Duration::ZERO);
1747    }
1748
1749    /**
1750     * Mark a package as done and remove it from pending dependencies.
1751     */
1752    fn mark_done(
1753        &mut self,
1754        pkgname: &PkgName,
1755        outcome: BuildOutcome,
1756        duration: Duration,
1757    ) {
1758        /*
1759         * Remove the package from the list of dependencies in all
1760         * packages it is listed in.  Once a package has no outstanding
1761         * dependencies remaining it is ready for building.
1762         */
1763        for dep in self.incoming.values_mut() {
1764            if dep.contains(pkgname) {
1765                dep.remove(pkgname);
1766            }
1767        }
1768        /*
1769         * The package was already removed from "incoming" when it started
1770         * building, so we only need to add it to "done".
1771         */
1772        self.done.insert(pkgname.clone());
1773
1774        // Record the result
1775        let scanpkg = self.scanpkgs.get(pkgname);
1776        let log_dir = Some(self.logdir.join(pkgname.pkgname()));
1777        self.results.push(BuildResult {
1778            pkgname: pkgname.clone(),
1779            pkgpath: scanpkg.and_then(|s| s.pkg_location.clone()),
1780            outcome,
1781            duration,
1782            log_dir,
1783        });
1784    }
1785
1786    /**
1787     * Recursively mark a package and its dependents as failed.
1788     */
1789    fn mark_failure(&mut self, pkgname: &PkgName, duration: Duration) {
1790        debug!(pkgname = %pkgname.pkgname(), "mark_failure called");
1791        let start = std::time::Instant::now();
1792        let mut broken: HashSet<PkgName> = HashSet::new();
1793        let mut to_check: Vec<PkgName> = vec![];
1794        to_check.push(pkgname.clone());
1795        /*
1796         * Starting with the original failed package, recursively loop through
1797         * adding any packages that depend on it, adding them to broken.
1798         * Uses precomputed reverse_deps for O(1) lookup instead of O(n) scan.
1799         */
1800        loop {
1801            /* No packages left to check, we're done. */
1802            let Some(badpkg) = to_check.pop() else {
1803                break;
1804            };
1805            /* Already checked this package. */
1806            if broken.contains(&badpkg) {
1807                continue;
1808            }
1809            /* Add all packages that depend on this one. */
1810            if let Some(dependents) = self.reverse_deps.get(&badpkg) {
1811                for pkg in dependents {
1812                    to_check.push(pkg.clone());
1813                }
1814            }
1815            broken.insert(badpkg);
1816        }
1817        debug!(pkgname = %pkgname.pkgname(), broken_count = broken.len(), elapsed_ms = start.elapsed().as_millis(), "mark_failure found broken packages");
1818        /*
1819         * We now have a full HashSet of affected packages.  Remove them from
1820         * incoming and move to failed.  The original failed package will
1821         * already be removed from incoming, we rely on .remove() accepting
1822         * this.
1823         */
1824        let is_original = |p: &PkgName| p == pkgname;
1825        for pkg in broken {
1826            self.incoming.remove(&pkg);
1827            self.failed.insert(pkg.clone());
1828
1829            // Record the result
1830            let scanpkg = self.scanpkgs.get(&pkg);
1831            let log_dir = Some(self.logdir.join(pkg.pkgname()));
1832            let (outcome, dur) = if is_original(&pkg) {
1833                (BuildOutcome::Failed("Build failed".to_string()), duration)
1834            } else {
1835                (
1836                    BuildOutcome::IndirectFailed(pkgname.pkgname().to_string()),
1837                    Duration::ZERO,
1838                )
1839            };
1840            self.results.push(BuildResult {
1841                pkgname: pkg,
1842                pkgpath: scanpkg.and_then(|s| s.pkg_location.clone()),
1843                outcome,
1844                duration: dur,
1845                log_dir,
1846            });
1847        }
1848        debug!(pkgname = %pkgname.pkgname(), total_results = self.results.len(), elapsed_ms = start.elapsed().as_millis(), "mark_failure completed");
1849    }
1850
1851    /**
1852     * Recursively mark a package as pre-failed and its dependents as
1853     * indirect-pre-failed.
1854     */
1855    #[allow(dead_code)]
1856    fn mark_prefailed(&mut self, pkgname: &PkgName, reason: String) {
1857        let mut broken: HashSet<PkgName> = HashSet::new();
1858        let mut to_check: Vec<PkgName> = vec![];
1859        to_check.push(pkgname.clone());
1860
1861        loop {
1862            let Some(badpkg) = to_check.pop() else {
1863                break;
1864            };
1865            if broken.contains(&badpkg) {
1866                continue;
1867            }
1868            for (pkg, deps) in &self.incoming {
1869                if deps.contains(&badpkg) {
1870                    to_check.push(pkg.clone());
1871                }
1872            }
1873            broken.insert(badpkg);
1874        }
1875
1876        let is_original = |p: &PkgName| p == pkgname;
1877        for pkg in broken {
1878            self.incoming.remove(&pkg);
1879            self.failed.insert(pkg.clone());
1880
1881            let scanpkg = self.scanpkgs.get(&pkg);
1882            let log_dir = Some(self.logdir.join(pkg.pkgname()));
1883            let outcome = if is_original(&pkg) {
1884                BuildOutcome::PreFailed(reason.clone())
1885            } else {
1886                BuildOutcome::IndirectPreFailed(pkgname.pkgname().to_string())
1887            };
1888            self.results.push(BuildResult {
1889                pkgname: pkg,
1890                pkgpath: scanpkg.and_then(|s| s.pkg_location.clone()),
1891                outcome,
1892                duration: Duration::ZERO,
1893                log_dir,
1894            });
1895        }
1896    }
1897
1898    /**
1899     * Get next package status.
1900     */
1901    fn get_next_build(&self) -> BuildStatus {
1902        /*
1903         * If incoming is empty then we're done.
1904         */
1905        if self.incoming.is_empty() {
1906            return BuildStatus::Done;
1907        }
1908
1909        /*
1910         * Get all packages in incoming that are cleared for building, ordered
1911         * by effective weight (own weight + transitive dependents' weights).
1912         */
1913        let mut pkgs: Vec<(PkgName, usize)> = self
1914            .incoming
1915            .iter()
1916            .filter(|(_, v)| v.is_empty())
1917            .map(|(k, _)| {
1918                (k.clone(), *self.effective_weights.get(k).unwrap_or(&100))
1919            })
1920            .collect();
1921
1922        /*
1923         * If no packages are returned then we're still waiting for
1924         * dependencies to finish.  Clients should keep retrying until this
1925         * changes.
1926         */
1927        if pkgs.is_empty() {
1928            return BuildStatus::NoneAvailable;
1929        }
1930
1931        /*
1932         * Order packages by build weight and return the highest.
1933         */
1934        pkgs.sort_by_key(|&(_, weight)| std::cmp::Reverse(weight));
1935        BuildStatus::Available(pkgs[0].0.clone())
1936    }
1937}
1938
1939impl Build {
1940    pub fn new(
1941        config: &Config,
1942        scanpkgs: IndexMap<PkgName, ResolvedIndex>,
1943        options: BuildOptions,
1944    ) -> Build {
1945        let sandbox = Sandbox::new(config);
1946        info!(
1947            package_count = scanpkgs.len(),
1948            sandbox_enabled = sandbox.enabled(),
1949            build_threads = config.build_threads(),
1950            ?options,
1951            "Creating new Build instance"
1952        );
1953        for (pkgname, index) in &scanpkgs {
1954            debug!(pkgname = %pkgname.pkgname(),
1955                pkgpath = ?index.pkg_location,
1956                depends_count = index.depends.len(),
1957                depends = ?index.depends.iter().map(|d| d.pkgname()).collect::<Vec<_>>(),
1958                "Package in build queue"
1959            );
1960        }
1961        Build {
1962            config: config.clone(),
1963            sandbox,
1964            scanpkgs,
1965            cached: IndexMap::new(),
1966            options,
1967        }
1968    }
1969
1970    /// Load cached build results from database.
1971    ///
1972    /// Returns the number of packages loaded from cache. Only loads results
1973    /// for packages that are in our build queue.
1974    pub fn load_cached_from_db(
1975        &mut self,
1976        db: &crate::db::Database,
1977    ) -> anyhow::Result<usize> {
1978        let mut count = 0;
1979        for pkgname in self.scanpkgs.keys() {
1980            if let Some(pkg) = db.get_package_by_name(pkgname.pkgname())? {
1981                if let Some(result) = db.get_build_result(pkg.id)? {
1982                    self.cached.insert(pkgname.clone(), result);
1983                    count += 1;
1984                }
1985            }
1986        }
1987        if count > 0 {
1988            info!(
1989                cached_count = count,
1990                "Loaded cached build results from database"
1991            );
1992        }
1993        Ok(count)
1994    }
1995
1996    /// Access completed build results.
1997    pub fn cached(&self) -> &IndexMap<PkgName, BuildResult> {
1998        &self.cached
1999    }
2000
2001    pub fn start(
2002        &mut self,
2003        ctx: &RunContext,
2004        db: &crate::db::Database,
2005    ) -> anyhow::Result<BuildSummary> {
2006        let started = Instant::now();
2007
2008        info!(package_count = self.scanpkgs.len(), "Build::start() called");
2009
2010        let shutdown_flag = Arc::clone(&ctx.shutdown);
2011
2012        /*
2013         * Populate BuildJobs.
2014         */
2015        debug!("Populating BuildJobs from scanpkgs");
2016        let mut incoming: HashMap<PkgName, HashSet<PkgName>> = HashMap::new();
2017        let mut reverse_deps: HashMap<PkgName, HashSet<PkgName>> =
2018            HashMap::new();
2019        for (pkgname, index) in &self.scanpkgs {
2020            let mut deps: HashSet<PkgName> = HashSet::new();
2021            for dep in &index.depends {
2022                // Only track dependencies that are in our build queue.
2023                // Dependencies outside scanpkgs are assumed to already be
2024                // installed (from a previous build) or will cause the build
2025                // to fail at runtime.
2026                if !self.scanpkgs.contains_key(dep) {
2027                    continue;
2028                }
2029                deps.insert(dep.clone());
2030                // Build reverse dependency map: dep -> packages that depend on it
2031                reverse_deps
2032                    .entry(dep.clone())
2033                    .or_default()
2034                    .insert(pkgname.clone());
2035            }
2036            trace!(pkgname = %pkgname.pkgname(),
2037                deps_count = deps.len(),
2038                deps = ?deps.iter().map(|d| d.pkgname()).collect::<Vec<_>>(),
2039                "Adding package to incoming build queue"
2040            );
2041            incoming.insert(pkgname.clone(), deps);
2042        }
2043
2044        /*
2045         * Process cached build results.
2046         */
2047        let mut done: HashSet<PkgName> = HashSet::new();
2048        let mut failed: HashSet<PkgName> = HashSet::new();
2049        let results: Vec<BuildResult> = Vec::new();
2050        let mut cached_count = 0usize;
2051
2052        for (pkgname, result) in &self.cached {
2053            match result.outcome {
2054                BuildOutcome::Success | BuildOutcome::UpToDate => {
2055                    // Completed package - remove from incoming, add to done
2056                    incoming.remove(pkgname);
2057                    done.insert(pkgname.clone());
2058                    // Remove from deps of other packages
2059                    for deps in incoming.values_mut() {
2060                        deps.remove(pkgname);
2061                    }
2062                    // Don't add to results - already in database
2063                    cached_count += 1;
2064                }
2065                BuildOutcome::Failed(_)
2066                | BuildOutcome::PreFailed(_)
2067                | BuildOutcome::IndirectFailed(_)
2068                | BuildOutcome::IndirectPreFailed(_) => {
2069                    // Failed package - remove from incoming, add to failed
2070                    incoming.remove(pkgname);
2071                    failed.insert(pkgname.clone());
2072                    // Don't add to results - already in database
2073                    cached_count += 1;
2074                }
2075            }
2076        }
2077
2078        /*
2079         * Propagate cached failures: any package in incoming that depends on
2080         * a failed package must also be marked as failed.
2081         */
2082        loop {
2083            let mut newly_failed: Vec<PkgName> = Vec::new();
2084            for (pkgname, deps) in &incoming {
2085                for dep in deps {
2086                    if failed.contains(dep) {
2087                        newly_failed.push(pkgname.clone());
2088                        break;
2089                    }
2090                }
2091            }
2092            if newly_failed.is_empty() {
2093                break;
2094            }
2095            for pkgname in newly_failed {
2096                incoming.remove(&pkgname);
2097                failed.insert(pkgname);
2098            }
2099        }
2100
2101        if cached_count > 0 {
2102            println!("Loaded {} cached build results", cached_count);
2103        }
2104
2105        info!(
2106            incoming_count = incoming.len(),
2107            scanpkgs_count = self.scanpkgs.len(),
2108            cached_count = cached_count,
2109            "BuildJobs populated"
2110        );
2111
2112        if incoming.is_empty() {
2113            return Ok(BuildSummary {
2114                duration: started.elapsed(),
2115                results,
2116                scan_failed: Vec::new(),
2117            });
2118        }
2119
2120        /*
2121         * Compute effective weights for build ordering.  The effective weight
2122         * is the package's own PBULK_WEIGHT plus the sum of weights of all
2123         * packages that transitively depend on it.  This prioritises building
2124         * packages that unblock the most downstream work.
2125         */
2126        let get_weight = |pkg: &PkgName| -> usize {
2127            self.scanpkgs
2128                .get(pkg)
2129                .and_then(|idx| idx.pbulk_weight.as_ref())
2130                .and_then(|w| w.parse().ok())
2131                .unwrap_or(100)
2132        };
2133
2134        let mut effective_weights: HashMap<PkgName, usize> = HashMap::new();
2135        let mut pending: HashMap<&PkgName, usize> = incoming
2136            .keys()
2137            .map(|p| (p, reverse_deps.get(p).map_or(0, |s| s.len())))
2138            .collect();
2139        let mut queue: VecDeque<&PkgName> =
2140            pending.iter().filter(|(_, c)| **c == 0).map(|(&p, _)| p).collect();
2141        while let Some(pkg) = queue.pop_front() {
2142            let mut total = get_weight(pkg);
2143            if let Some(dependents) = reverse_deps.get(pkg) {
2144                for dep in dependents {
2145                    total += effective_weights.get(dep).unwrap_or(&0);
2146                }
2147            }
2148            effective_weights.insert(pkg.clone(), total);
2149            for dep in incoming.get(pkg).iter().flat_map(|s| s.iter()) {
2150                if let Some(c) = pending.get_mut(dep) {
2151                    *c -= 1;
2152                    if *c == 0 {
2153                        queue.push_back(dep);
2154                    }
2155                }
2156            }
2157        }
2158
2159        let running: HashSet<PkgName> = HashSet::new();
2160        let logdir = self.config.logdir().clone();
2161        let jobs = BuildJobs {
2162            scanpkgs: self.scanpkgs.clone(),
2163            incoming,
2164            reverse_deps,
2165            effective_weights,
2166            running,
2167            done,
2168            failed,
2169            results,
2170            logdir,
2171            cached_count,
2172        };
2173
2174        // Create sandboxes before starting progress display
2175        if self.sandbox.enabled() {
2176            println!("Creating sandboxes...");
2177            for i in 0..self.config.build_threads() {
2178                if let Err(e) = self.sandbox.create(i) {
2179                    // Rollback: destroy sandboxes including the failed one (may be partial)
2180                    for j in (0..=i).rev() {
2181                        if let Err(destroy_err) = self.sandbox.destroy(j) {
2182                            eprintln!(
2183                                "Warning: failed to destroy sandbox {}: {}",
2184                                j, destroy_err
2185                            );
2186                        }
2187                    }
2188                    return Err(e);
2189                }
2190            }
2191        }
2192
2193        println!("Building packages...");
2194
2195        // Set up multi-line progress display using ratatui inline viewport
2196        let progress = Arc::new(Mutex::new(
2197            MultiProgress::new(
2198                "Building",
2199                "Built",
2200                self.scanpkgs.len(),
2201                self.config.build_threads(),
2202            )
2203            .expect("Failed to initialize progress display"),
2204        ));
2205
2206        // Mark cached packages in progress display
2207        if cached_count > 0 {
2208            if let Ok(mut p) = progress.lock() {
2209                p.state_mut().cached = cached_count;
2210            }
2211        }
2212
2213        // Flag to stop the refresh thread
2214        let stop_refresh = Arc::new(AtomicBool::new(false));
2215
2216        // Spawn a thread to periodically refresh the display (for timer updates)
2217        let progress_refresh = Arc::clone(&progress);
2218        let stop_flag = Arc::clone(&stop_refresh);
2219        let shutdown_for_refresh = Arc::clone(&shutdown_flag);
2220        let refresh_thread = std::thread::spawn(move || {
2221            while !stop_flag.load(Ordering::Relaxed)
2222                && !shutdown_for_refresh.load(Ordering::SeqCst)
2223            {
2224                if let Ok(mut p) = progress_refresh.lock() {
2225                    // Check for keyboard events (like 'v' for view toggle)
2226                    let _ = p.poll_events();
2227                    let _ = p.render_throttled();
2228                }
2229                std::thread::sleep(Duration::from_millis(50));
2230            }
2231        });
2232
2233        /*
2234         * Configure a mananger channel.  This is used for clients to indicate
2235         * to the manager that they are ready for work.
2236         */
2237        let (manager_tx, manager_rx) = mpsc::channel::<ChannelCommand>();
2238
2239        /*
2240         * Client threads.  Each client has its own channel to the manager,
2241         * with the client sending ready status on the manager channel, and
2242         * receiving instructions on its private channel.
2243         */
2244        let mut threads = vec![];
2245        let mut clients: HashMap<usize, Sender<ChannelCommand>> =
2246            HashMap::new();
2247        for i in 0..self.config.build_threads() {
2248            let (client_tx, client_rx) = mpsc::channel::<ChannelCommand>();
2249            clients.insert(i, client_tx);
2250            let manager_tx = manager_tx.clone();
2251            let thread = std::thread::spawn(move || {
2252                loop {
2253                    // Use send() which can fail if receiver is dropped (manager shutdown)
2254                    if manager_tx.send(ChannelCommand::ClientReady(i)).is_err()
2255                    {
2256                        break;
2257                    }
2258
2259                    let Ok(msg) = client_rx.recv() else {
2260                        break;
2261                    };
2262
2263                    match msg {
2264                        ChannelCommand::ComeBackLater => {
2265                            std::thread::sleep(Duration::from_millis(100));
2266                            continue;
2267                        }
2268                        ChannelCommand::JobData(pkg) => {
2269                            let pkgname = pkg.pkginfo.pkgname.clone();
2270                            trace!(pkgname = %pkgname.pkgname(), worker = i, "Worker starting build");
2271                            let build_start = Instant::now();
2272                            let result = pkg.build(&manager_tx);
2273                            let duration = build_start.elapsed();
2274                            trace!(pkgname = %pkgname.pkgname(), worker = i, elapsed_ms = duration.as_millis(), "Worker build() returned");
2275                            match result {
2276                                Ok(PackageBuildResult::Success) => {
2277                                    trace!(pkgname = %pkgname.pkgname(), "Worker sending JobSuccess");
2278                                    let _ = manager_tx.send(
2279                                        ChannelCommand::JobSuccess(
2280                                            pkgname, duration,
2281                                        ),
2282                                    );
2283                                }
2284                                Ok(PackageBuildResult::Skipped) => {
2285                                    trace!(pkgname = %pkgname.pkgname(), "Worker sending JobSkipped");
2286                                    let _ = manager_tx.send(
2287                                        ChannelCommand::JobSkipped(pkgname),
2288                                    );
2289                                }
2290                                Ok(PackageBuildResult::Failed) => {
2291                                    trace!(pkgname = %pkgname.pkgname(), "Worker sending JobFailed");
2292                                    let _ = manager_tx.send(
2293                                        ChannelCommand::JobFailed(
2294                                            pkgname, duration,
2295                                        ),
2296                                    );
2297                                }
2298                                Err(e) => {
2299                                    trace!(pkgname = %pkgname.pkgname(), "Worker sending JobError");
2300                                    let _ = manager_tx.send(
2301                                        ChannelCommand::JobError((
2302                                            pkgname, duration, e,
2303                                        )),
2304                                    );
2305                                }
2306                            }
2307                            continue;
2308                        }
2309                        ChannelCommand::Quit | ChannelCommand::Shutdown => {
2310                            break;
2311                        }
2312                        _ => todo!(),
2313                    }
2314                }
2315            });
2316            threads.push(thread);
2317        }
2318
2319        /*
2320         * Manager thread.  Read incoming commands from clients and reply
2321         * accordingly.  Returns the build results via a channel.
2322         */
2323        let config = self.config.clone();
2324        let sandbox = self.sandbox.clone();
2325        let options = self.options.clone();
2326        let progress_clone = Arc::clone(&progress);
2327        let shutdown_for_manager = Arc::clone(&shutdown_flag);
2328        let (results_tx, results_rx) = mpsc::channel::<Vec<BuildResult>>();
2329        let (interrupted_tx, interrupted_rx) = mpsc::channel::<bool>();
2330        // Channel for completed results to save immediately
2331        let (completed_tx, completed_rx) = mpsc::channel::<BuildResult>();
2332        let manager = std::thread::spawn(move || {
2333            let mut clients = clients.clone();
2334            let config = config.clone();
2335            let sandbox = sandbox.clone();
2336            let mut jobs = jobs.clone();
2337            let mut was_interrupted = false;
2338
2339            // Track which thread is building which package
2340            let mut thread_packages: HashMap<usize, PkgName> = HashMap::new();
2341
2342            loop {
2343                // Check shutdown flag periodically
2344                if shutdown_for_manager.load(Ordering::SeqCst) {
2345                    // Suppress all further output
2346                    if let Ok(mut p) = progress_clone.lock() {
2347                        p.state_mut().suppress();
2348                    }
2349                    // Send shutdown to all remaining clients
2350                    for (_, client) in clients.drain() {
2351                        let _ = client.send(ChannelCommand::Shutdown);
2352                    }
2353                    was_interrupted = true;
2354                    break;
2355                }
2356
2357                // Use recv_timeout to check shutdown flag periodically
2358                let command =
2359                    match manager_rx.recv_timeout(Duration::from_millis(50)) {
2360                        Ok(cmd) => cmd,
2361                        Err(mpsc::RecvTimeoutError::Timeout) => continue,
2362                        Err(mpsc::RecvTimeoutError::Disconnected) => break,
2363                    };
2364
2365                match command {
2366                    ChannelCommand::ClientReady(c) => {
2367                        let client = clients.get(&c).unwrap();
2368                        match jobs.get_next_build() {
2369                            BuildStatus::Available(pkg) => {
2370                                let pkginfo = jobs.scanpkgs.get(&pkg).unwrap();
2371                                jobs.incoming.remove(&pkg);
2372                                jobs.running.insert(pkg.clone());
2373
2374                                // Update thread progress
2375                                thread_packages.insert(c, pkg.clone());
2376                                if let Ok(mut p) = progress_clone.lock() {
2377                                    p.clear_output_buffer(c);
2378                                    p.state_mut()
2379                                        .set_worker_active(c, pkg.pkgname());
2380                                    let _ = p.render_throttled();
2381                                }
2382
2383                                let _ = client.send(ChannelCommand::JobData(
2384                                    Box::new(PackageBuild {
2385                                        id: c,
2386                                        config: config.clone(),
2387                                        pkginfo: pkginfo.clone(),
2388                                        sandbox: sandbox.clone(),
2389                                        options: options.clone(),
2390                                    }),
2391                                ));
2392                            }
2393                            BuildStatus::NoneAvailable => {
2394                                if let Ok(mut p) = progress_clone.lock() {
2395                                    p.clear_output_buffer(c);
2396                                    p.state_mut().set_worker_idle(c);
2397                                    let _ = p.render_throttled();
2398                                }
2399                                let _ =
2400                                    client.send(ChannelCommand::ComeBackLater);
2401                            }
2402                            BuildStatus::Done => {
2403                                if let Ok(mut p) = progress_clone.lock() {
2404                                    p.clear_output_buffer(c);
2405                                    p.state_mut().set_worker_idle(c);
2406                                    let _ = p.render_throttled();
2407                                }
2408                                let _ = client.send(ChannelCommand::Quit);
2409                                clients.remove(&c);
2410                                if clients.is_empty() {
2411                                    break;
2412                                }
2413                            }
2414                        };
2415                    }
2416                    ChannelCommand::JobSuccess(pkgname, duration) => {
2417                        jobs.mark_success(&pkgname, duration);
2418                        jobs.running.remove(&pkgname);
2419
2420                        // Send result for immediate saving
2421                        if let Some(result) = jobs.results.last() {
2422                            let _ = completed_tx.send(result.clone());
2423                        }
2424
2425                        // Don't update UI if we're shutting down
2426                        if shutdown_for_manager.load(Ordering::SeqCst) {
2427                            continue;
2428                        }
2429
2430                        // Find which thread completed and mark idle
2431                        if let Ok(mut p) = progress_clone.lock() {
2432                            let _ = p.print_status(&format!(
2433                                "       Built {} ({})",
2434                                pkgname.pkgname(),
2435                                format_duration(duration)
2436                            ));
2437                            p.state_mut().increment_completed();
2438                            for (tid, pkg) in &thread_packages {
2439                                if pkg == &pkgname {
2440                                    p.clear_output_buffer(*tid);
2441                                    p.state_mut().set_worker_idle(*tid);
2442                                    break;
2443                                }
2444                            }
2445                            let _ = p.render_throttled();
2446                        }
2447                    }
2448                    ChannelCommand::JobSkipped(pkgname) => {
2449                        jobs.mark_up_to_date(&pkgname);
2450                        jobs.running.remove(&pkgname);
2451
2452                        // Send result for immediate saving
2453                        if let Some(result) = jobs.results.last() {
2454                            let _ = completed_tx.send(result.clone());
2455                        }
2456
2457                        // Don't update UI if we're shutting down
2458                        if shutdown_for_manager.load(Ordering::SeqCst) {
2459                            continue;
2460                        }
2461
2462                        // Find which thread completed and mark idle
2463                        if let Ok(mut p) = progress_clone.lock() {
2464                            let _ = p.print_status(&format!(
2465                                "     Skipped {} (up-to-date)",
2466                                pkgname.pkgname()
2467                            ));
2468                            p.state_mut().increment_skipped();
2469                            for (tid, pkg) in &thread_packages {
2470                                if pkg == &pkgname {
2471                                    p.clear_output_buffer(*tid);
2472                                    p.state_mut().set_worker_idle(*tid);
2473                                    break;
2474                                }
2475                            }
2476                            let _ = p.render_throttled();
2477                        }
2478                    }
2479                    ChannelCommand::JobFailed(pkgname, duration) => {
2480                        let results_before = jobs.results.len();
2481                        jobs.mark_failure(&pkgname, duration);
2482                        jobs.running.remove(&pkgname);
2483
2484                        // Send all new results for immediate saving
2485                        for result in jobs.results.iter().skip(results_before) {
2486                            let _ = completed_tx.send(result.clone());
2487                        }
2488
2489                        // Don't update UI if we're shutting down
2490                        if shutdown_for_manager.load(Ordering::SeqCst) {
2491                            continue;
2492                        }
2493
2494                        // Find which thread failed and mark idle
2495                        if let Ok(mut p) = progress_clone.lock() {
2496                            let _ = p.print_status(&format!(
2497                                "      Failed {} ({})",
2498                                pkgname.pkgname(),
2499                                format_duration(duration)
2500                            ));
2501                            p.state_mut().increment_failed();
2502                            for (tid, pkg) in &thread_packages {
2503                                if pkg == &pkgname {
2504                                    p.clear_output_buffer(*tid);
2505                                    p.state_mut().set_worker_idle(*tid);
2506                                    break;
2507                                }
2508                            }
2509                            let _ = p.render_throttled();
2510                        }
2511                    }
2512                    ChannelCommand::JobError((pkgname, duration, e)) => {
2513                        let results_before = jobs.results.len();
2514                        jobs.mark_failure(&pkgname, duration);
2515                        jobs.running.remove(&pkgname);
2516
2517                        // Send all new results for immediate saving
2518                        for result in jobs.results.iter().skip(results_before) {
2519                            let _ = completed_tx.send(result.clone());
2520                        }
2521
2522                        // Don't update UI if we're shutting down
2523                        if shutdown_for_manager.load(Ordering::SeqCst) {
2524                            tracing::error!(error = %e, pkgname = %pkgname.pkgname(), "Build error");
2525                            continue;
2526                        }
2527
2528                        // Find which thread errored and mark idle
2529                        if let Ok(mut p) = progress_clone.lock() {
2530                            let _ = p.print_status(&format!(
2531                                "      Failed {} ({})",
2532                                pkgname.pkgname(),
2533                                format_duration(duration)
2534                            ));
2535                            p.state_mut().increment_failed();
2536                            for (tid, pkg) in &thread_packages {
2537                                if pkg == &pkgname {
2538                                    p.clear_output_buffer(*tid);
2539                                    p.state_mut().set_worker_idle(*tid);
2540                                    break;
2541                                }
2542                            }
2543                            let _ = p.render_throttled();
2544                        }
2545                        tracing::error!(error = %e, pkgname = %pkgname.pkgname(), "Build error");
2546                    }
2547                    ChannelCommand::StageUpdate(tid, stage) => {
2548                        if let Ok(mut p) = progress_clone.lock() {
2549                            p.state_mut()
2550                                .set_worker_stage(tid, stage.as_deref());
2551                            let _ = p.render_throttled();
2552                        }
2553                    }
2554                    ChannelCommand::OutputLines(tid, lines) => {
2555                        if let Ok(mut p) = progress_clone.lock() {
2556                            if let Some(buf) = p.output_buffer_mut(tid) {
2557                                for line in lines {
2558                                    buf.push(line);
2559                                }
2560                            }
2561                        }
2562                    }
2563                    _ => {}
2564                }
2565            }
2566
2567            // Send results and interrupted status back
2568            debug!(
2569                result_count = jobs.results.len(),
2570                "Manager sending results back"
2571            );
2572            let _ = results_tx.send(jobs.results);
2573            let _ = interrupted_tx.send(was_interrupted);
2574        });
2575
2576        threads.push(manager);
2577        debug!("Waiting for worker threads to complete");
2578        let join_start = Instant::now();
2579        for thread in threads {
2580            thread.join().expect("thread panicked");
2581        }
2582        debug!(
2583            elapsed_ms = join_start.elapsed().as_millis(),
2584            "Worker threads completed"
2585        );
2586
2587        // Save all completed results to database immediately
2588        let mut saved_count = 0;
2589        while let Ok(result) = completed_rx.try_recv() {
2590            if let Err(e) = db.store_build_by_name(&result) {
2591                warn!(
2592                    pkgname = %result.pkgname.pkgname(),
2593                    error = %e,
2594                    "Failed to save build result"
2595                );
2596            } else {
2597                saved_count += 1;
2598            }
2599        }
2600        if saved_count > 0 {
2601            debug!(saved_count, "Saved build results to database");
2602        }
2603
2604        // Stop the refresh thread
2605        stop_refresh.store(true, Ordering::Relaxed);
2606        let _ = refresh_thread.join();
2607
2608        // Check if we were interrupted
2609        let was_interrupted = interrupted_rx.recv().unwrap_or(false);
2610
2611        // Print appropriate summary
2612        if let Ok(mut p) = progress.lock() {
2613            if was_interrupted {
2614                let _ = p.finish_interrupted();
2615            } else {
2616                let _ = p.finish();
2617            }
2618        }
2619
2620        // Collect results from manager
2621        debug!("Collecting results from manager");
2622        let results = results_rx.recv().unwrap_or_default();
2623        debug!(result_count = results.len(), "Collected results from manager");
2624        let summary = BuildSummary {
2625            duration: started.elapsed(),
2626            results,
2627            scan_failed: Vec::new(),
2628        };
2629
2630        if self.sandbox.enabled() {
2631            debug!("Destroying sandboxes");
2632            let destroy_start = Instant::now();
2633            self.sandbox.destroy_all(self.config.build_threads())?;
2634            debug!(
2635                elapsed_ms = destroy_start.elapsed().as_millis(),
2636                "Sandboxes destroyed"
2637            );
2638        }
2639
2640        Ok(summary)
2641    }
2642}