bob/
config.rs

1/*
2 * Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
3 *
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted, provided that the above
6 * copyright notice and this permission notice appear in all copies.
7 *
8 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15 */
16
17//! Configuration file parsing (Lua format).
18//!
19//! Bob uses Lua configuration files for maximum flexibility. The configuration
20//! defines paths to pkgsrc, packages to build, sandbox setup, and build scripts.
21//!
22//! # Configuration File Structure
23//!
24//! A configuration file has four main sections:
25//!
26//! - [`options`](#options-section) - General build options (optional)
27//! - [`pkgsrc`](#pkgsrc-section) - pkgsrc paths and package list (required)
28//! - [`scripts`](#scripts-section) - Build script paths (required)
29//! - [`sandboxes`](#sandboxes-section) - Sandbox configuration (optional)
30//!
31//! # Options Section
32//!
33//! The `options` section is optional. All fields have defaults.
34//!
35//! | Field | Type | Default | Description |
36//! |-------|------|---------|-------------|
37//! | `build_threads` | integer | 1 | Number of parallel build sandboxes. Each sandbox builds one package at a time. |
38//! | `scan_threads` | integer | 1 | Number of parallel scan processes for dependency discovery. |
39//! | `strict_scan` | boolean | false | If true, abort on scan errors. If false, continue and report failures separately. |
40//! | `log_level` | string | "info" | Log level: "trace", "debug", "info", "warn", or "error". Can be overridden by `RUST_LOG` env var. |
41//!
42//! # Pkgsrc Section
43//!
44//! The `pkgsrc` section is required and defines paths to pkgsrc components.
45//!
46//! ## Required Fields
47//!
48//! | Field | Type | Description |
49//! |-------|------|-------------|
50//! | `basedir` | string | Absolute path to the pkgsrc source tree (e.g., `/data/pkgsrc`). |
51//! | `logdir` | string | Directory for all logs. Per-package build logs go in subdirectories. Failed builds leave logs here; successful builds clean up. |
52//! | `make` | string | Absolute path to the bmake binary (e.g., `/usr/pkg/bin/bmake`). |
53//!
54//! ## Optional Fields
55//!
56//! | Field | Type | Default | Description |
57//! |-------|------|---------|-------------|
58//! | `bootstrap` | string | none | Path to a bootstrap tarball. Required on non-NetBSD systems. Unpacked into each sandbox before builds. |
59//! | `build_user` | string | none | Unprivileged user to run builds as. If set, builds run as this user instead of root. |
60//! | `cachevars` | table | `{}` | List of pkgsrc variable names to fetch once and cache. These are set in the environment for scans and builds (e.g., `{"NATIVE_OPSYS", "NATIVE_OS_VERSION"}`). |
61//! | `env` | function or table | `{}` | Environment variables for builds. Can be a table of key-value pairs, or a function receiving package metadata and returning a table. See [Environment Function](#environment-function). |
62//! | `pkgpaths` | table | `{}` | List of package paths to build (e.g., `{"mail/mutt", "www/curl"}`). Dependencies are discovered automatically. |
63//! | `save_wrkdir_patterns` | table | `{}` | Glob patterns for files to preserve from WRKDIR on build failure (e.g., `{"**/config.log"}`). |
64//! | `tar` | string | `tar` | Path to a tar binary capable of extracting the bootstrap kit. Defaults to `tar` in PATH. |
65//!
66//! ## Environment Function
67//!
68//! The `env` field can be a function that returns environment variables for each
69//! package build. The function receives a `pkg` table with the following fields:
70//!
71//! | Field | Type | Description |
72//! |-------|------|-------------|
73//! | `pkgname` | string | Package name with version (e.g., `mutt-2.2.12`). |
74//! | `pkgpath` | string | Package path in pkgsrc (e.g., `mail/mutt`). |
75//! | `all_depends` | string | Space-separated list of all transitive dependency paths. |
76//! | `depends` | string | Space-separated list of direct dependency package names. |
77//! | `scan_depends` | string | Space-separated list of scan-time dependency paths. |
78//! | `categories` | string | Package categories from `CATEGORIES`. |
79//! | `maintainer` | string | Package maintainer email from `MAINTAINER`. |
80//! | `bootstrap_pkg` | string | Value of `BOOTSTRAP_PKG` if set. |
81//! | `usergroup_phase` | string | Value of `USERGROUP_PHASE` if set. |
82//! | `use_destdir` | string | Value of `USE_DESTDIR`. |
83//! | `multi_version` | string | Value of `MULTI_VERSION` if set. |
84//! | `pbulk_weight` | string | Value of `PBULK_WEIGHT` if set. |
85//! | `pkg_skip_reason` | string | Value of `PKG_SKIP_REASON` if set. |
86//! | `pkg_fail_reason` | string | Value of `PKG_FAIL_REASON` if set. |
87//! | `no_bin_on_ftp` | string | Value of `NO_BIN_ON_FTP` if set. |
88//! | `restricted` | string | Value of `RESTRICTED` if set. |
89//!
90//! # Scripts Section
91//!
92//! The `scripts` section defines paths to build scripts. Relative paths are
93//! resolved from the configuration file's directory.
94//!
95//! | Script | Required | Description |
96//! |--------|----------|-------------|
97//! | `pre-build` | no | Executed before each package build. Used for per-build sandbox setup (e.g., unpacking bootstrap kit). Receives environment variables listed in [Script Environment](#script-environment). |
98//! | `post-build` | no | Executed after each package build completes (success or failure). |
99//!
100//! ## Script Environment
101//!
102//! Build scripts receive these environment variables:
103//!
104//! | Variable | Description |
105//! |----------|-------------|
106//! | `bob_logdir` | Path to the log directory. |
107//! | `bob_make` | Path to the bmake binary. |
108//! | `bob_packages` | Path to the packages directory. |
109//! | `bob_pkg_dbdir` | PKG_DBDIR from pkgsrc. |
110//! | `bob_pkg_refcount_dbdir` | PKG_REFCOUNT_DBDIR from pkgsrc. |
111//! | `bob_pkgtools` | Path to the pkg tools directory. |
112//! | `bob_pkgsrc` | Path to the pkgsrc source tree. |
113//! | `bob_prefix` | Installation prefix. |
114//! | `bob_tar` | Path to the tar binary. |
115//! | `bob_build_user` | Unprivileged build user, if configured. |
116//! | `bob_bootstrap` | Path to the bootstrap tarball, if configured. |
117//!
118//! # Sandboxes Section
119//!
120//! The `sandboxes` section is optional. When present, builds run in isolated
121//! chroot environments.
122//!
123//! | Field | Type | Required | Description |
124//! |-------|------|----------|-------------|
125//! | `basedir` | string | yes | Base directory for sandbox roots. Sandboxes are created as numbered subdirectories (`basedir/0`, `basedir/1`, etc.). |
126//! | `actions` | table | yes | List of actions to perform during sandbox setup. See the [`action`](crate::action) module for details. |
127
128use crate::action::Action;
129use crate::sandbox::Sandbox;
130use crate::scan::ResolvedPackage;
131use anyhow::{Context, Result, anyhow, bail};
132use mlua::{Lua, RegistryKey, Result as LuaResult, Table, Value};
133use pkgsrc::PkgPath;
134use std::collections::HashMap;
135use std::path::{Path, PathBuf};
136use std::sync::{Arc, Mutex};
137
138/// Environment variables retrieved from pkgsrc.
139///
140/// These values are queried from pkgsrc's mk.conf via bmake and represent
141/// the actual paths pkgsrc is configured to use. This struct is created
142/// after sandbox setup and passed to build operations.
143#[derive(Clone, Debug)]
144pub struct PkgsrcEnv {
145    /// PACKAGES directory for binary packages.
146    pub packages: PathBuf,
147    /// PKG_TOOLS_BIN directory containing pkg_add, pkg_delete, etc.
148    pub pkgtools: PathBuf,
149    /// PREFIX installation directory.
150    pub prefix: PathBuf,
151    /// PKG_DBDIR for installed package database.
152    pub pkg_dbdir: PathBuf,
153    /// PKG_REFCOUNT_DBDIR for refcounted files database.
154    pub pkg_refcount_dbdir: PathBuf,
155    /// Cached pkgsrc variables from the `cachevars` config option.
156    pub cachevars: HashMap<String, String>,
157}
158
159impl PkgsrcEnv {
160    /// Fetch pkgsrc environment variables by querying bmake.
161    ///
162    /// This must be called after sandbox 0 is created if sandboxes are enabled,
163    /// since bmake may only exist inside the sandbox.
164    pub fn fetch(config: &Config, sandbox: &Sandbox) -> Result<Self> {
165        const REQUIRED_VARS: &[&str] = &[
166            "PACKAGES",
167            "PKG_DBDIR",
168            "PKG_REFCOUNT_DBDIR",
169            "PKG_TOOLS_BIN",
170            "PREFIX",
171        ];
172
173        let user_cachevars = config.cachevars();
174        let mut all_varnames: Vec<&str> = REQUIRED_VARS.to_vec();
175        for v in user_cachevars {
176            all_varnames.push(v.as_str());
177        }
178
179        let varnames_arg = all_varnames.join(" ");
180        let script = format!(
181            "cd {}/pkgtools/pkg_install && {} show-vars VARNAMES=\"{}\"\n",
182            config.pkgsrc().display(),
183            config.make().display(),
184            varnames_arg
185        );
186
187        let child = sandbox.execute_script(0, &script, vec![])?;
188        let output = child
189            .wait_with_output()
190            .context("Failed to execute bmake show-vars")?;
191
192        if !output.status.success() {
193            let stderr = String::from_utf8_lossy(&output.stderr);
194            bail!("Failed to query pkgsrc variables: {}", stderr.trim());
195        }
196
197        let stdout = String::from_utf8_lossy(&output.stdout);
198        let lines: Vec<&str> = stdout.lines().collect();
199
200        if lines.len() != all_varnames.len() {
201            bail!(
202                "Expected {} variables from pkgsrc, got {}",
203                all_varnames.len(),
204                lines.len()
205            );
206        }
207
208        let mut values: HashMap<&str, &str> = HashMap::new();
209        for (varname, value) in all_varnames.iter().zip(&lines) {
210            values.insert(varname, value);
211        }
212
213        for varname in REQUIRED_VARS {
214            if values.get(varname).is_none_or(|v| v.is_empty()) {
215                bail!("pkgsrc returned empty value for {}", varname);
216            }
217        }
218
219        let mut cachevars: HashMap<String, String> = HashMap::new();
220        for varname in user_cachevars {
221            if let Some(value) = values.get(varname.as_str()) {
222                if !value.is_empty() {
223                    cachevars.insert(varname.clone(), (*value).to_string());
224                }
225            }
226        }
227
228        Ok(PkgsrcEnv {
229            packages: PathBuf::from(values["PACKAGES"]),
230            pkgtools: PathBuf::from(values["PKG_TOOLS_BIN"]),
231            prefix: PathBuf::from(values["PREFIX"]),
232            pkg_dbdir: PathBuf::from(values["PKG_DBDIR"]),
233            pkg_refcount_dbdir: PathBuf::from(values["PKG_REFCOUNT_DBDIR"]),
234            cachevars,
235        })
236    }
237}
238
239/// Holds the Lua state for evaluating env functions.
240#[derive(Clone)]
241pub struct LuaEnv {
242    lua: Arc<Mutex<Lua>>,
243    env_key: Option<Arc<RegistryKey>>,
244}
245
246impl std::fmt::Debug for LuaEnv {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        f.debug_struct("LuaEnv")
249            .field("has_env", &self.env_key.is_some())
250            .finish()
251    }
252}
253
254impl Default for LuaEnv {
255    fn default() -> Self {
256        Self { lua: Arc::new(Mutex::new(Lua::new())), env_key: None }
257    }
258}
259
260impl LuaEnv {
261    /// Get environment variables for a package by calling the env function.
262    /// Returns a HashMap of VAR_NAME -> value.
263    pub fn get_env(
264        &self,
265        pkg: &ResolvedPackage,
266    ) -> Result<HashMap<String, String>, String> {
267        let Some(env_key) = &self.env_key else {
268            return Ok(HashMap::new());
269        };
270
271        let lua =
272            self.lua.lock().map_err(|e| format!("Lua lock error: {}", e))?;
273
274        // Get the env value from registry
275        let env_value: Value = lua
276            .registry_value(env_key)
277            .map_err(|e| format!("Failed to get env from registry: {}", e))?;
278
279        let idx = &pkg.index;
280
281        let result_table: Table = match env_value {
282            // If it's a function, call it with pkg info
283            Value::Function(func) => {
284                let pkg_table = lua
285                    .create_table()
286                    .map_err(|e| format!("Failed to create table: {}", e))?;
287
288                // Set all ScanIndex fields
289                pkg_table
290                    .set("pkgname", idx.pkgname.to_string())
291                    .map_err(|e| format!("Failed to set pkgname: {}", e))?;
292                pkg_table
293                    .set("pkgpath", pkg.pkgpath.as_path().display().to_string())
294                    .map_err(|e| format!("Failed to set pkgpath: {}", e))?;
295                pkg_table
296                    .set(
297                        "all_depends",
298                        idx.all_depends
299                            .as_ref()
300                            .map(|deps| {
301                                deps.iter()
302                                    .map(|d| {
303                                        d.pkgpath()
304                                            .as_path()
305                                            .display()
306                                            .to_string()
307                                    })
308                                    .collect::<Vec<_>>()
309                                    .join(" ")
310                            })
311                            .unwrap_or_default(),
312                    )
313                    .map_err(|e| format!("Failed to set all_depends: {}", e))?;
314                pkg_table
315                    .set(
316                        "pkg_skip_reason",
317                        idx.pkg_skip_reason.clone().unwrap_or_default(),
318                    )
319                    .map_err(|e| {
320                        format!("Failed to set pkg_skip_reason: {}", e)
321                    })?;
322                pkg_table
323                    .set(
324                        "pkg_fail_reason",
325                        idx.pkg_fail_reason.clone().unwrap_or_default(),
326                    )
327                    .map_err(|e| {
328                        format!("Failed to set pkg_fail_reason: {}", e)
329                    })?;
330                pkg_table
331                    .set(
332                        "no_bin_on_ftp",
333                        idx.no_bin_on_ftp.clone().unwrap_or_default(),
334                    )
335                    .map_err(|e| {
336                        format!("Failed to set no_bin_on_ftp: {}", e)
337                    })?;
338                pkg_table
339                    .set(
340                        "restricted",
341                        idx.restricted.clone().unwrap_or_default(),
342                    )
343                    .map_err(|e| format!("Failed to set restricted: {}", e))?;
344                pkg_table
345                    .set(
346                        "categories",
347                        idx.categories.clone().unwrap_or_default(),
348                    )
349                    .map_err(|e| format!("Failed to set categories: {}", e))?;
350                pkg_table
351                    .set(
352                        "maintainer",
353                        idx.maintainer.clone().unwrap_or_default(),
354                    )
355                    .map_err(|e| format!("Failed to set maintainer: {}", e))?;
356                pkg_table
357                    .set(
358                        "use_destdir",
359                        idx.use_destdir.clone().unwrap_or_default(),
360                    )
361                    .map_err(|e| format!("Failed to set use_destdir: {}", e))?;
362                pkg_table
363                    .set(
364                        "bootstrap_pkg",
365                        idx.bootstrap_pkg.clone().unwrap_or_default(),
366                    )
367                    .map_err(|e| {
368                        format!("Failed to set bootstrap_pkg: {}", e)
369                    })?;
370                pkg_table
371                    .set(
372                        "usergroup_phase",
373                        idx.usergroup_phase.clone().unwrap_or_default(),
374                    )
375                    .map_err(|e| {
376                        format!("Failed to set usergroup_phase: {}", e)
377                    })?;
378                pkg_table
379                    .set(
380                        "scan_depends",
381                        idx.scan_depends
382                            .as_ref()
383                            .map(|deps| {
384                                deps.iter()
385                                    .map(|p| p.display().to_string())
386                                    .collect::<Vec<_>>()
387                                    .join(" ")
388                            })
389                            .unwrap_or_default(),
390                    )
391                    .map_err(|e| {
392                        format!("Failed to set scan_depends: {}", e)
393                    })?;
394                pkg_table
395                    .set(
396                        "pbulk_weight",
397                        idx.pbulk_weight.clone().unwrap_or_default(),
398                    )
399                    .map_err(|e| {
400                        format!("Failed to set pbulk_weight: {}", e)
401                    })?;
402                pkg_table
403                    .set(
404                        "multi_version",
405                        idx.multi_version
406                            .as_ref()
407                            .map(|v| v.join(" "))
408                            .unwrap_or_default(),
409                    )
410                    .map_err(|e| {
411                        format!("Failed to set multi_version: {}", e)
412                    })?;
413                pkg_table
414                    .set(
415                        "depends",
416                        pkg.depends()
417                            .iter()
418                            .map(|d| d.to_string())
419                            .collect::<Vec<_>>()
420                            .join(" "),
421                    )
422                    .map_err(|e| format!("Failed to set depends: {}", e))?;
423
424                func.call(pkg_table).map_err(|e| {
425                    format!("Failed to call env function: {}", e)
426                })?
427            }
428            // If it's a table, use it directly
429            Value::Table(t) => t,
430            Value::Nil => return Ok(HashMap::new()),
431            _ => return Err("env must be a function or table".to_string()),
432        };
433
434        // Convert Lua table to HashMap
435        let mut env = HashMap::new();
436        for pair in result_table.pairs::<String, String>() {
437            let (k, v) = pair
438                .map_err(|e| format!("Failed to iterate env table: {}", e))?;
439            env.insert(k, v);
440        }
441
442        Ok(env)
443    }
444}
445
446/// Main configuration structure.
447#[derive(Clone, Debug, Default)]
448pub struct Config {
449    file: ConfigFile,
450    log_level: String,
451    lua_env: LuaEnv,
452}
453
454/// Parsed configuration file contents.
455#[derive(Clone, Debug, Default)]
456pub struct ConfigFile {
457    /// The `options` section.
458    pub options: Option<Options>,
459    /// The `pkgsrc` section.
460    pub pkgsrc: Pkgsrc,
461    /// The `scripts` section (script name -> path).
462    pub scripts: HashMap<String, PathBuf>,
463    /// The `sandboxes` section.
464    pub sandboxes: Option<Sandboxes>,
465}
466
467/// General build options from the `options` section.
468///
469/// All fields are optional; defaults are used when not specified:
470/// - `build_threads`: 1
471/// - `scan_threads`: 1
472/// - `log_level`: "info"
473#[derive(Clone, Debug, Default)]
474pub struct Options {
475    /// Number of parallel build sandboxes.
476    pub build_threads: Option<usize>,
477    /// Number of parallel scan processes.
478    pub scan_threads: Option<usize>,
479    /// If true, abort on scan errors. If false, continue and report failures.
480    pub strict_scan: Option<bool>,
481    /// Log level: "trace", "debug", "info", "warn", or "error".
482    pub log_level: Option<String>,
483}
484
485/// pkgsrc-related configuration from the `pkgsrc` section.
486///
487/// # Required Fields
488///
489/// - `basedir`: Path to pkgsrc source tree
490/// - `logdir`: Directory for logs
491/// - `make`: Path to bmake binary
492///
493/// # Optional Fields
494///
495/// - `bootstrap`: Path to bootstrap tarball (required on non-NetBSD systems)
496/// - `build_user`: Unprivileged user for builds
497/// - `pkgpaths`: List of packages to build
498/// - `save_wrkdir_patterns`: Glob patterns for files to save on build failure
499/// - `tar`: Path to tar binary (defaults to `tar`)
500#[derive(Clone, Debug, Default)]
501pub struct Pkgsrc {
502    /// Path to pkgsrc source tree.
503    pub basedir: PathBuf,
504    /// Path to bootstrap tarball (required on non-NetBSD).
505    pub bootstrap: Option<PathBuf>,
506    /// Unprivileged user for builds.
507    pub build_user: Option<String>,
508    /// Directory for logs.
509    pub logdir: PathBuf,
510    /// Path to bmake binary.
511    pub make: PathBuf,
512    /// List of packages to build.
513    pub pkgpaths: Option<Vec<PkgPath>>,
514    /// Glob patterns for files to save from WRKDIR on failure.
515    pub save_wrkdir_patterns: Vec<String>,
516    /// pkgsrc variables to cache and re-set in each environment run.
517    pub cachevars: Vec<String>,
518    /// Path to tar binary (defaults to `tar` in PATH).
519    pub tar: Option<PathBuf>,
520}
521
522/// Sandbox configuration from the `sandboxes` section.
523///
524/// When this section is present in the configuration, builds are performed
525/// in isolated chroot environments.
526///
527/// # Example
528///
529/// ```lua
530/// sandboxes = {
531///     basedir = "/data/chroot",
532///     actions = {
533///         { action = "mount", fs = "proc", dir = "/proc" },
534///         { action = "copy", dir = "/etc" },
535///     },
536/// }
537/// ```
538#[derive(Clone, Debug, Default)]
539pub struct Sandboxes {
540    /// Base directory for sandbox roots (e.g., `/data/chroot`).
541    ///
542    /// Individual sandboxes are created as numbered subdirectories:
543    /// `basedir/0`, `basedir/1`, etc.
544    pub basedir: PathBuf,
545    /// Actions to perform during sandbox setup/teardown.
546    ///
547    /// See [`Action`] for details.
548    pub actions: Vec<Action>,
549}
550
551impl Config {
552    /// Load configuration from a Lua file.
553    ///
554    /// # Arguments
555    ///
556    /// * `config_path` - Path to configuration file, or `None` to use `./config.lua`
557    ///
558    /// # Errors
559    ///
560    /// Returns an error if the configuration file doesn't exist or contains
561    /// invalid Lua syntax.
562    pub fn load(config_path: Option<&Path>) -> Result<Config> {
563        /*
564         * Load user-supplied configuration file, or the default location.
565         */
566        let filename = if let Some(path) = config_path {
567            path.to_path_buf()
568        } else {
569            std::env::current_dir()
570                .context("Unable to determine current directory")?
571                .join("config.lua")
572        };
573
574        /* A configuration file is mandatory. */
575        if !filename.exists() {
576            anyhow::bail!(
577                "Configuration file {} does not exist",
578                filename.display()
579            );
580        }
581
582        /*
583         * Parse configuration file as Lua.
584         */
585        let (mut file, lua_env) =
586            load_lua(&filename).map_err(|e| anyhow!(e)).with_context(|| {
587                format!(
588                    "Unable to parse Lua configuration file {}",
589                    filename.display()
590                )
591            })?;
592
593        /*
594         * Parse scripts section.  Paths are resolved relative to config dir
595         * if not absolute.
596         */
597        let base_dir = filename.parent().unwrap_or_else(|| Path::new("."));
598        let mut newscripts: HashMap<String, PathBuf> = HashMap::new();
599        for (k, v) in &file.scripts {
600            let fullpath =
601                if v.is_relative() { base_dir.join(v) } else { v.clone() };
602            newscripts.insert(k.clone(), fullpath);
603        }
604        file.scripts = newscripts;
605
606        /*
607         * Validate bootstrap path exists if specified.
608         */
609        if let Some(ref bootstrap) = file.pkgsrc.bootstrap {
610            if !bootstrap.exists() {
611                anyhow::bail!(
612                    "pkgsrc.bootstrap file {} does not exist",
613                    bootstrap.display()
614                );
615            }
616        }
617
618        /*
619         * Set log_level from config file, defaulting to "info".
620         */
621        let log_level = if let Some(opts) = &file.options {
622            opts.log_level.clone().unwrap_or_else(|| "info".to_string())
623        } else {
624            "info".to_string()
625        };
626
627        Ok(Config { file, log_level, lua_env })
628    }
629
630    pub fn build_threads(&self) -> usize {
631        if let Some(opts) = &self.file.options {
632            opts.build_threads.unwrap_or(1)
633        } else {
634            1
635        }
636    }
637
638    pub fn scan_threads(&self) -> usize {
639        if let Some(opts) = &self.file.options {
640            opts.scan_threads.unwrap_or(1)
641        } else {
642            1
643        }
644    }
645
646    pub fn strict_scan(&self) -> bool {
647        if let Some(opts) = &self.file.options {
648            opts.strict_scan.unwrap_or(false)
649        } else {
650            false
651        }
652    }
653
654    pub fn script(&self, key: &str) -> Option<&PathBuf> {
655        self.file.scripts.get(key)
656    }
657
658    pub fn make(&self) -> &PathBuf {
659        &self.file.pkgsrc.make
660    }
661
662    pub fn pkgpaths(&self) -> &Option<Vec<PkgPath>> {
663        &self.file.pkgsrc.pkgpaths
664    }
665
666    pub fn pkgsrc(&self) -> &PathBuf {
667        &self.file.pkgsrc.basedir
668    }
669
670    pub fn sandboxes(&self) -> &Option<Sandboxes> {
671        &self.file.sandboxes
672    }
673
674    pub fn log_level(&self) -> &str {
675        &self.log_level
676    }
677
678    pub fn logdir(&self) -> &PathBuf {
679        &self.file.pkgsrc.logdir
680    }
681
682    pub fn save_wrkdir_patterns(&self) -> &[String] {
683        self.file.pkgsrc.save_wrkdir_patterns.as_slice()
684    }
685
686    pub fn tar(&self) -> Option<&PathBuf> {
687        self.file.pkgsrc.tar.as_ref()
688    }
689
690    pub fn build_user(&self) -> Option<&str> {
691        self.file.pkgsrc.build_user.as_deref()
692    }
693
694    pub fn bootstrap(&self) -> Option<&PathBuf> {
695        self.file.pkgsrc.bootstrap.as_ref()
696    }
697
698    /// Return list of pkgsrc variable names to cache.
699    pub fn cachevars(&self) -> &[String] {
700        self.file.pkgsrc.cachevars.as_slice()
701    }
702
703    /// Get environment variables for a package from the Lua env function/table.
704    pub fn get_pkg_env(
705        &self,
706        pkg: &ResolvedPackage,
707    ) -> Result<std::collections::HashMap<String, String>, String> {
708        self.lua_env.get_env(pkg)
709    }
710
711    /// Return environment variables for script execution.
712    ///
713    /// If `pkgsrc_env` is provided, includes the pkgsrc-derived variables
714    /// (packages, pkgtools, prefix, pkg_dbdir, pkg_refcount_dbdir).
715    /// Return environment variables for script execution.
716    ///
717    /// If `pkgsrc_env` is provided, includes the pkgsrc-derived variables
718    /// (packages, pkgtools, prefix, pkg_dbdir, pkg_refcount_dbdir) as well
719    /// as the cached variables from the `cachevars` config option.
720    pub fn script_env(
721        &self,
722        pkgsrc_env: Option<&PkgsrcEnv>,
723    ) -> Vec<(String, String)> {
724        let mut envs = vec![
725            ("bob_logdir".to_string(), format!("{}", self.logdir().display())),
726            ("bob_make".to_string(), format!("{}", self.make().display())),
727            ("bob_pkgsrc".to_string(), format!("{}", self.pkgsrc().display())),
728        ];
729        if let Some(env) = pkgsrc_env {
730            envs.push((
731                "bob_packages".to_string(),
732                env.packages.display().to_string(),
733            ));
734            envs.push((
735                "bob_pkgtools".to_string(),
736                env.pkgtools.display().to_string(),
737            ));
738            envs.push((
739                "bob_prefix".to_string(),
740                env.prefix.display().to_string(),
741            ));
742            envs.push((
743                "bob_pkg_dbdir".to_string(),
744                env.pkg_dbdir.display().to_string(),
745            ));
746            envs.push((
747                "bob_pkg_refcount_dbdir".to_string(),
748                env.pkg_refcount_dbdir.display().to_string(),
749            ));
750            for (key, value) in &env.cachevars {
751                envs.push((key.clone(), value.clone()));
752            }
753        }
754        let tar_value = self
755            .tar()
756            .map(|t| t.display().to_string())
757            .unwrap_or_else(|| "tar".to_string());
758        envs.push(("bob_tar".to_string(), tar_value));
759        if let Some(build_user) = self.build_user() {
760            envs.push(("bob_build_user".to_string(), build_user.to_string()));
761        }
762        if let Some(bootstrap) = self.bootstrap() {
763            envs.push((
764                "bob_bootstrap".to_string(),
765                format!("{}", bootstrap.display()),
766            ));
767        }
768        envs
769    }
770
771    /// Validate the configuration, checking that required paths and files exist.
772    pub fn validate(&self) -> Result<(), Vec<String>> {
773        let mut errors: Vec<String> = Vec::new();
774
775        // Check pkgsrc directory exists
776        if !self.file.pkgsrc.basedir.exists() {
777            errors.push(format!(
778                "pkgsrc basedir does not exist: {}",
779                self.file.pkgsrc.basedir.display()
780            ));
781        }
782
783        // Check make binary exists (only on host if sandboxes not enabled)
784        // When sandboxes are enabled, the make binary is inside the sandbox
785        if self.file.sandboxes.is_none() && !self.file.pkgsrc.make.exists() {
786            errors.push(format!(
787                "make binary does not exist: {}",
788                self.file.pkgsrc.make.display()
789            ));
790        }
791
792        // Check scripts exist
793        for (name, path) in &self.file.scripts {
794            if !path.exists() {
795                errors.push(format!(
796                    "Script '{}' does not exist: {}",
797                    name,
798                    path.display()
799                ));
800            } else if !path.is_file() {
801                errors.push(format!(
802                    "Script '{}' is not a file: {}",
803                    name,
804                    path.display()
805                ));
806            }
807        }
808
809        // Check sandbox basedir is writable if sandboxes enabled
810        if let Some(sandboxes) = &self.file.sandboxes {
811            // Check parent directory exists or can be created
812            if let Some(parent) = sandboxes.basedir.parent() {
813                if !parent.exists() {
814                    errors.push(format!(
815                        "Sandbox basedir parent does not exist: {}",
816                        parent.display()
817                    ));
818                }
819            }
820        }
821
822        // Check logdir can be created
823        if let Some(parent) = self.file.pkgsrc.logdir.parent() {
824            if !parent.exists() {
825                errors.push(format!(
826                    "logdir parent directory does not exist: {}",
827                    parent.display()
828                ));
829            }
830        }
831
832        if errors.is_empty() { Ok(()) } else { Err(errors) }
833    }
834}
835
836/// Load a Lua configuration file and return a ConfigFile and LuaEnv.
837fn load_lua(filename: &Path) -> Result<(ConfigFile, LuaEnv), String> {
838    let lua = Lua::new();
839
840    // Add config directory to package.path so require() finds relative modules
841    if let Some(config_dir) = filename.parent() {
842        let path_setup = format!(
843            "package.path = '{}' .. '/?.lua;' .. package.path",
844            config_dir.display()
845        );
846        lua.load(&path_setup)
847            .exec()
848            .map_err(|e| format!("Failed to set package.path: {}", e))?;
849    }
850
851    lua.load(filename)
852        .exec()
853        .map_err(|e| format!("Lua execution error: {}", e))?;
854
855    // Get the global table (Lua script should set global variables)
856    let globals = lua.globals();
857
858    // Parse each section
859    let options = parse_options(&globals)
860        .map_err(|e| format!("Error parsing options config: {}", e))?;
861    let pkgsrc_table: Table = globals
862        .get("pkgsrc")
863        .map_err(|e| format!("Error getting pkgsrc config: {}", e))?;
864    let pkgsrc = parse_pkgsrc(&globals)
865        .map_err(|e| format!("Error parsing pkgsrc config: {}", e))?;
866    let scripts = parse_scripts(&globals)
867        .map_err(|e| format!("Error parsing scripts config: {}", e))?;
868    let sandboxes = parse_sandboxes(&globals)
869        .map_err(|e| format!("Error parsing sandboxes config: {}", e))?;
870
871    // Store env function/table in registry if it exists
872    let env_key = if let Ok(env_value) = pkgsrc_table.get::<Value>("env") {
873        if !env_value.is_nil() {
874            let key = lua.create_registry_value(env_value).map_err(|e| {
875                format!("Failed to store env in registry: {}", e)
876            })?;
877            Some(Arc::new(key))
878        } else {
879            None
880        }
881    } else {
882        None
883    };
884
885    let lua_env = LuaEnv { lua: Arc::new(Mutex::new(lua)), env_key };
886
887    let config = ConfigFile { options, pkgsrc, scripts, sandboxes };
888
889    Ok((config, lua_env))
890}
891
892fn parse_options(globals: &Table) -> LuaResult<Option<Options>> {
893    let options: Value = globals.get("options")?;
894    if options.is_nil() {
895        return Ok(None);
896    }
897
898    let table = options
899        .as_table()
900        .ok_or_else(|| mlua::Error::runtime("'options' must be a table"))?;
901
902    const KNOWN_KEYS: &[&str] =
903        &["build_threads", "scan_threads", "strict_scan", "log_level"];
904    warn_unknown_keys(table, "options", KNOWN_KEYS);
905
906    Ok(Some(Options {
907        build_threads: table.get("build_threads").ok(),
908        scan_threads: table.get("scan_threads").ok(),
909        strict_scan: table.get("strict_scan").ok(),
910        log_level: table.get("log_level").ok(),
911    }))
912}
913
914/// Warn about unknown keys in a Lua table.
915fn warn_unknown_keys(table: &Table, table_name: &str, known_keys: &[&str]) {
916    for (key, _) in table.pairs::<String, Value>().flatten() {
917        if !known_keys.contains(&key.as_str()) {
918            eprintln!("Warning: unknown config key '{}.{}'", table_name, key);
919        }
920    }
921}
922
923fn get_required_string(table: &Table, field: &str) -> LuaResult<String> {
924    let value: Value = table.get(field)?;
925    match value {
926        Value::String(s) => Ok(s.to_str()?.to_string()),
927        Value::Integer(n) => Ok(n.to_string()),
928        Value::Number(n) => Ok(n.to_string()),
929        Value::Nil => Err(mlua::Error::runtime(format!(
930            "missing required field '{}'",
931            field
932        ))),
933        _ => Err(mlua::Error::runtime(format!(
934            "field '{}' must be a string, got {}",
935            field,
936            value.type_name()
937        ))),
938    }
939}
940
941fn parse_pkgsrc(globals: &Table) -> LuaResult<Pkgsrc> {
942    let pkgsrc: Table = globals.get("pkgsrc")?;
943
944    const KNOWN_KEYS: &[&str] = &[
945        "basedir",
946        "bootstrap",
947        "build_user",
948        "cachevars",
949        "env",
950        "logdir",
951        "make",
952        "pkgpaths",
953        "save_wrkdir_patterns",
954        "tar",
955    ];
956    warn_unknown_keys(&pkgsrc, "pkgsrc", KNOWN_KEYS);
957
958    let basedir = get_required_string(&pkgsrc, "basedir")?;
959    let bootstrap: Option<PathBuf> =
960        pkgsrc.get::<Option<String>>("bootstrap")?.map(PathBuf::from);
961    let build_user: Option<String> =
962        pkgsrc.get::<Option<String>>("build_user")?;
963    let logdir = get_required_string(&pkgsrc, "logdir")?;
964    let make = get_required_string(&pkgsrc, "make")?;
965    let tar: Option<PathBuf> =
966        pkgsrc.get::<Option<String>>("tar")?.map(PathBuf::from);
967
968    let pkgpaths: Option<Vec<PkgPath>> =
969        match pkgsrc.get::<Value>("pkgpaths")? {
970            Value::Nil => None,
971            Value::Table(t) => {
972                let paths: Vec<PkgPath> = t
973                    .sequence_values::<String>()
974                    .filter_map(|r| r.ok())
975                    .filter_map(|s| PkgPath::new(&s).ok())
976                    .collect();
977                if paths.is_empty() { None } else { Some(paths) }
978            }
979            _ => None,
980        };
981
982    let save_wrkdir_patterns: Vec<String> =
983        match pkgsrc.get::<Value>("save_wrkdir_patterns")? {
984            Value::Nil => Vec::new(),
985            Value::Table(t) => {
986                t.sequence_values::<String>().filter_map(|r| r.ok()).collect()
987            }
988            _ => Vec::new(),
989        };
990
991    let cachevars: Vec<String> = match pkgsrc.get::<Value>("cachevars")? {
992        Value::Nil => Vec::new(),
993        Value::Table(t) => {
994            t.sequence_values::<String>().filter_map(|r| r.ok()).collect()
995        }
996        _ => Vec::new(),
997    };
998
999    Ok(Pkgsrc {
1000        basedir: PathBuf::from(basedir),
1001        bootstrap,
1002        build_user,
1003        cachevars,
1004        logdir: PathBuf::from(logdir),
1005        make: PathBuf::from(make),
1006        pkgpaths,
1007        save_wrkdir_patterns,
1008        tar,
1009    })
1010}
1011
1012fn parse_scripts(globals: &Table) -> LuaResult<HashMap<String, PathBuf>> {
1013    let scripts: Value = globals.get("scripts")?;
1014    if scripts.is_nil() {
1015        return Ok(HashMap::new());
1016    }
1017
1018    let table = scripts
1019        .as_table()
1020        .ok_or_else(|| mlua::Error::runtime("'scripts' must be a table"))?;
1021
1022    let mut result = HashMap::new();
1023    for pair in table.pairs::<String, String>() {
1024        let (k, v) = pair?;
1025        result.insert(k, PathBuf::from(v));
1026    }
1027
1028    Ok(result)
1029}
1030
1031fn parse_sandboxes(globals: &Table) -> LuaResult<Option<Sandboxes>> {
1032    let sandboxes: Value = globals.get("sandboxes")?;
1033    if sandboxes.is_nil() {
1034        return Ok(None);
1035    }
1036
1037    let table = sandboxes
1038        .as_table()
1039        .ok_or_else(|| mlua::Error::runtime("'sandboxes' must be a table"))?;
1040
1041    const KNOWN_KEYS: &[&str] = &["actions", "basedir"];
1042    warn_unknown_keys(table, "sandboxes", KNOWN_KEYS);
1043
1044    let basedir: String = table.get("basedir")?;
1045
1046    let actions_value: Value = table.get("actions")?;
1047    let actions = if actions_value.is_nil() {
1048        Vec::new()
1049    } else {
1050        let actions_table = actions_value.as_table().ok_or_else(|| {
1051            mlua::Error::runtime("'sandboxes.actions' must be a table")
1052        })?;
1053        parse_actions(actions_table)?
1054    };
1055
1056    Ok(Some(Sandboxes { basedir: PathBuf::from(basedir), actions }))
1057}
1058
1059fn parse_actions(table: &Table) -> LuaResult<Vec<Action>> {
1060    table.sequence_values::<Table>().map(|v| Action::from_lua(&v?)).collect()
1061}