bob/
config.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//! 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//! | `verbose` | boolean | false | Enable verbose output. Can be overridden by the `-v` command line flag. |
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//! | `packages` | string | Directory where binary packages are stored after successful builds. |
54//! | `pkgtools` | string | Directory containing `pkg_add`, `pkg_delete`, and other pkg tools (e.g., `/usr/pkg/sbin`). |
55//! | `prefix` | string | Installation prefix for packages (e.g., `/usr/pkg`). Must match the bootstrap kit. |
56//! | `tar` | string | Absolute path to a tar binary capable of extracting the bootstrap kit. |
57//!
58//! ## Optional Fields
59//!
60//! | Field | Type | Default | Description |
61//! |-------|------|---------|-------------|
62//! | `bootstrap` | string | none | Path to a bootstrap tarball. Required on non-NetBSD systems. Unpacked into each sandbox before builds. |
63//! | `build_user` | string | none | Unprivileged user to run builds as. If set, builds run as this user instead of root. |
64//! | `pkgpaths` | table | `{}` | List of package paths to build (e.g., `{"mail/mutt", "www/curl"}`). Dependencies are discovered automatically. |
65//! | `report_dir` | string | `logdir` | Directory for HTML build reports. Defaults to the `logdir` directory. |
66//! | `save_wrkdir_patterns` | table | `{}` | Glob patterns for files to preserve from WRKDIR on build failure (e.g., `{"**/config.log"}`). |
67//! | `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). |
68//!
69//! ## Environment Function
70//!
71//! The `env` field can be a function that returns environment variables for each
72//! package build. The function receives a `pkg` table with the following fields:
73//!
74//! | Field | Type | Description |
75//! |-------|------|-------------|
76//! | `pkgname` | string | Package name with version (e.g., `mutt-2.2.12`). |
77//! | `pkgpath` | string | Package path in pkgsrc (e.g., `mail/mutt`). |
78//! | `all_depends` | string | Space-separated list of all transitive dependency paths. |
79//! | `depends` | string | Space-separated list of direct dependency package names. |
80//! | `scan_depends` | string | Space-separated list of scan-time dependency paths. |
81//! | `categories` | string | Package categories from `CATEGORIES`. |
82//! | `maintainer` | string | Package maintainer email from `MAINTAINER`. |
83//! | `bootstrap_pkg` | string | Value of `BOOTSTRAP_PKG` if set. |
84//! | `usergroup_phase` | string | Value of `USERGROUP_PHASE` if set. |
85//! | `use_destdir` | string | Value of `USE_DESTDIR`. |
86//! | `multi_version` | string | Value of `MULTI_VERSION` if set. |
87//! | `pbulk_weight` | string | Value of `PBULK_WEIGHT` if set. |
88//! | `pkg_skip_reason` | string | Value of `PKG_SKIP_REASON` if set. |
89//! | `pkg_fail_reason` | string | Value of `PKG_FAIL_REASON` if set. |
90//! | `no_bin_on_ftp` | string | Value of `NO_BIN_ON_FTP` if set. |
91//! | `restricted` | string | Value of `RESTRICTED` if set. |
92//!
93//! # Scripts Section
94//!
95//! The `scripts` section defines paths to build scripts. Relative paths are
96//! resolved from the configuration file's directory.
97//!
98//! | Script | Required | Description |
99//! |--------|----------|-------------|
100//! | `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). |
101//! | `post-build` | no | Executed after each package build completes (success or failure). |
102//!
103//! ## Script Environment
104//!
105//! Build scripts receive these environment variables:
106//!
107//! | Variable | Description |
108//! |----------|-------------|
109//! | `bob_logdir` | Path to the log directory. |
110//! | `bob_make` | Path to the bmake binary. |
111//! | `bob_packages` | Path to the packages directory. |
112//! | `bob_pkgtools` | Path to the pkg tools directory. |
113//! | `bob_pkgsrc` | Path to the pkgsrc source tree. |
114//! | `bob_prefix` | Installation prefix. |
115//! | `bob_tar` | Path to the tar binary. |
116//! | `bob_build_user` | Unprivileged build user, if configured. |
117//! | `bob_bootstrap` | Path to the bootstrap tarball, if configured. |
118//! | `bob_status_fd` | File descriptor for sending status messages back to bob. |
119//!
120//! ## Status Messages
121//!
122//! Scripts can send status updates to bob by writing to the file descriptor
123//! in `bob_status_fd`:
124//!
125//! | Message | Description |
126//! |---------|-------------|
127//! | `stage:<name>` | Build entered a new phase (e.g., `stage:configure`). Displayed in the TUI. |
128//! | `skipped` | Package was skipped (e.g., already up-to-date). |
129//!
130//! # Sandboxes Section
131//!
132//! The `sandboxes` section is optional. When present, builds run in isolated
133//! chroot environments.
134//!
135//! | Field | Type | Required | Description |
136//! |-------|------|----------|-------------|
137//! | `basedir` | string | yes | Base directory for sandbox roots. Sandboxes are created as numbered subdirectories (`basedir/0`, `basedir/1`, etc.). |
138//! | `actions` | table | yes | List of actions to perform during sandbox setup. See the [`action`](crate::action) module for details. |
139
140use crate::action::Action;
141use crate::scan::ResolvedIndex;
142use anyhow::{Context, Result, anyhow};
143use mlua::{Lua, RegistryKey, Result as LuaResult, Table, Value};
144use pkgsrc::PkgPath;
145use std::collections::HashMap;
146use std::path::{Path, PathBuf};
147use std::sync::{Arc, Mutex};
148
149/// Holds the Lua state for evaluating env functions.
150#[derive(Clone)]
151pub struct LuaEnv {
152    lua: Arc<Mutex<Lua>>,
153    env_key: Option<Arc<RegistryKey>>,
154}
155
156impl std::fmt::Debug for LuaEnv {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.debug_struct("LuaEnv")
159            .field("has_env", &self.env_key.is_some())
160            .finish()
161    }
162}
163
164impl Default for LuaEnv {
165    fn default() -> Self {
166        Self { lua: Arc::new(Mutex::new(Lua::new())), env_key: None }
167    }
168}
169
170impl LuaEnv {
171    /// Get environment variables for a package by calling the env function.
172    /// Returns a HashMap of VAR_NAME -> value.
173    pub fn get_env(
174        &self,
175        idx: &ResolvedIndex,
176    ) -> Result<HashMap<String, String>, String> {
177        let Some(env_key) = &self.env_key else {
178            return Ok(HashMap::new());
179        };
180
181        let lua =
182            self.lua.lock().map_err(|e| format!("Lua lock error: {}", e))?;
183
184        // Get the env value from registry
185        let env_value: Value = lua
186            .registry_value(env_key)
187            .map_err(|e| format!("Failed to get env from registry: {}", e))?;
188
189        let result_table: Table = match env_value {
190            // If it's a function, call it with pkg info
191            Value::Function(func) => {
192                let pkg_table = lua
193                    .create_table()
194                    .map_err(|e| format!("Failed to create table: {}", e))?;
195
196                // Set all ScanIndex fields
197                pkg_table
198                    .set("pkgname", idx.pkgname.to_string())
199                    .map_err(|e| format!("Failed to set pkgname: {}", e))?;
200                pkg_table
201                    .set(
202                        "pkgpath",
203                        idx.pkg_location
204                            .as_ref()
205                            .map(|p| p.as_path().display().to_string())
206                            .unwrap_or_default(),
207                    )
208                    .map_err(|e| format!("Failed to set pkgpath: {}", e))?;
209                pkg_table
210                    .set(
211                        "all_depends",
212                        idx.all_depends
213                            .as_ref()
214                            .map(|deps| {
215                                deps.iter()
216                                    .map(|d| {
217                                        d.pkgpath()
218                                            .as_path()
219                                            .display()
220                                            .to_string()
221                                    })
222                                    .collect::<Vec<_>>()
223                                    .join(" ")
224                            })
225                            .unwrap_or_default(),
226                    )
227                    .map_err(|e| format!("Failed to set all_depends: {}", e))?;
228                pkg_table
229                    .set(
230                        "pkg_skip_reason",
231                        idx.pkg_skip_reason.clone().unwrap_or_default(),
232                    )
233                    .map_err(|e| {
234                        format!("Failed to set pkg_skip_reason: {}", e)
235                    })?;
236                pkg_table
237                    .set(
238                        "pkg_fail_reason",
239                        idx.pkg_fail_reason.clone().unwrap_or_default(),
240                    )
241                    .map_err(|e| {
242                        format!("Failed to set pkg_fail_reason: {}", e)
243                    })?;
244                pkg_table
245                    .set(
246                        "no_bin_on_ftp",
247                        idx.no_bin_on_ftp.clone().unwrap_or_default(),
248                    )
249                    .map_err(|e| {
250                        format!("Failed to set no_bin_on_ftp: {}", e)
251                    })?;
252                pkg_table
253                    .set(
254                        "restricted",
255                        idx.restricted.clone().unwrap_or_default(),
256                    )
257                    .map_err(|e| format!("Failed to set restricted: {}", e))?;
258                pkg_table
259                    .set(
260                        "categories",
261                        idx.categories.clone().unwrap_or_default(),
262                    )
263                    .map_err(|e| format!("Failed to set categories: {}", e))?;
264                pkg_table
265                    .set(
266                        "maintainer",
267                        idx.maintainer.clone().unwrap_or_default(),
268                    )
269                    .map_err(|e| format!("Failed to set maintainer: {}", e))?;
270                pkg_table
271                    .set(
272                        "use_destdir",
273                        idx.use_destdir.clone().unwrap_or_default(),
274                    )
275                    .map_err(|e| format!("Failed to set use_destdir: {}", e))?;
276                pkg_table
277                    .set(
278                        "bootstrap_pkg",
279                        idx.bootstrap_pkg.clone().unwrap_or_default(),
280                    )
281                    .map_err(|e| {
282                        format!("Failed to set bootstrap_pkg: {}", e)
283                    })?;
284                pkg_table
285                    .set(
286                        "usergroup_phase",
287                        idx.usergroup_phase.clone().unwrap_or_default(),
288                    )
289                    .map_err(|e| {
290                        format!("Failed to set usergroup_phase: {}", e)
291                    })?;
292                pkg_table
293                    .set(
294                        "scan_depends",
295                        idx.scan_depends
296                            .as_ref()
297                            .map(|deps| {
298                                deps.iter()
299                                    .map(|p| p.display().to_string())
300                                    .collect::<Vec<_>>()
301                                    .join(" ")
302                            })
303                            .unwrap_or_default(),
304                    )
305                    .map_err(|e| {
306                        format!("Failed to set scan_depends: {}", e)
307                    })?;
308                pkg_table
309                    .set(
310                        "pbulk_weight",
311                        idx.pbulk_weight.clone().unwrap_or_default(),
312                    )
313                    .map_err(|e| {
314                        format!("Failed to set pbulk_weight: {}", e)
315                    })?;
316                pkg_table
317                    .set(
318                        "multi_version",
319                        idx.multi_version
320                            .as_ref()
321                            .map(|v| v.join(" "))
322                            .unwrap_or_default(),
323                    )
324                    .map_err(|e| {
325                        format!("Failed to set multi_version: {}", e)
326                    })?;
327                pkg_table
328                    .set(
329                        "depends",
330                        idx.depends
331                            .iter()
332                            .map(|d| d.to_string())
333                            .collect::<Vec<_>>()
334                            .join(" "),
335                    )
336                    .map_err(|e| format!("Failed to set depends: {}", e))?;
337
338                func.call(pkg_table).map_err(|e| {
339                    format!("Failed to call env function: {}", e)
340                })?
341            }
342            // If it's a table, use it directly
343            Value::Table(t) => t,
344            Value::Nil => return Ok(HashMap::new()),
345            _ => return Err("env must be a function or table".to_string()),
346        };
347
348        // Convert Lua table to HashMap
349        let mut env = HashMap::new();
350        for pair in result_table.pairs::<String, String>() {
351            let (k, v) = pair
352                .map_err(|e| format!("Failed to iterate env table: {}", e))?;
353            env.insert(k, v);
354        }
355
356        Ok(env)
357    }
358}
359
360/// Main configuration structure.
361///
362/// Load configuration using [`Config::load`], then access settings through
363/// the provided methods.
364///
365/// # Example
366///
367/// ```no_run
368/// use bob::Config;
369/// use std::path::Path;
370///
371/// let config = Config::load(Some(Path::new("/data/bob/config.lua")), false)?;
372/// println!("Building with {} threads", config.build_threads());
373/// # Ok::<(), anyhow::Error>(())
374/// ```
375#[derive(Clone, Debug, Default)]
376pub struct Config {
377    file: ConfigFile,
378    filename: PathBuf,
379    verbose: bool,
380    lua_env: LuaEnv,
381}
382
383/// Parsed configuration file contents.
384#[derive(Clone, Debug, Default)]
385pub struct ConfigFile {
386    /// The `options` section.
387    pub options: Option<Options>,
388    /// The `pkgsrc` section.
389    pub pkgsrc: Pkgsrc,
390    /// The `scripts` section (script name -> path).
391    pub scripts: HashMap<String, PathBuf>,
392    /// The `sandboxes` section.
393    pub sandboxes: Option<Sandboxes>,
394}
395
396/// General build options from the `options` section.
397///
398/// All fields are optional; defaults are used when not specified:
399/// - `build_threads`: 1
400/// - `scan_threads`: 1
401/// - `verbose`: false
402#[derive(Clone, Debug, Default)]
403pub struct Options {
404    /// Number of parallel build sandboxes.
405    pub build_threads: Option<usize>,
406    /// Number of parallel scan processes.
407    pub scan_threads: Option<usize>,
408    /// If true, abort on scan errors. If false, continue and report failures.
409    pub strict_scan: Option<bool>,
410    /// Enable verbose output.
411    pub verbose: Option<bool>,
412}
413
414/// pkgsrc-related configuration from the `pkgsrc` section.
415///
416/// # Required Fields
417///
418/// - `basedir`: Path to pkgsrc source tree
419/// - `logdir`: Directory for logs
420/// - `make`: Path to bmake binary
421/// - `packages`: Directory for built packages
422/// - `pkgtools`: Directory containing pkg_add/pkg_delete
423/// - `prefix`: Installation prefix (e.g., `/usr/pkg`)
424/// - `tar`: Path to tar binary
425///
426/// # Optional Fields
427///
428/// - `bootstrap`: Path to bootstrap tarball (required on non-NetBSD systems)
429/// - `build_user`: Unprivileged user for builds
430/// - `pkgpaths`: List of packages to build
431/// - `report_dir`: Directory for HTML reports
432/// - `save_wrkdir_patterns`: Glob patterns for files to save on build failure
433#[derive(Clone, Debug, Default)]
434pub struct Pkgsrc {
435    /// Path to pkgsrc source tree.
436    pub basedir: PathBuf,
437    /// Path to bootstrap tarball (required on non-NetBSD).
438    pub bootstrap: Option<PathBuf>,
439    /// Unprivileged user for builds.
440    pub build_user: Option<String>,
441    /// Directory for logs.
442    pub logdir: PathBuf,
443    /// Path to bmake binary.
444    pub make: PathBuf,
445    /// Directory for built packages.
446    pub packages: PathBuf,
447    /// Directory containing pkg_add/pkg_delete.
448    pub pkgtools: PathBuf,
449    /// List of packages to build.
450    pub pkgpaths: Option<Vec<PkgPath>>,
451    /// Installation prefix.
452    pub prefix: PathBuf,
453    /// Directory for HTML reports.
454    pub report_dir: Option<PathBuf>,
455    /// Glob patterns for files to save from WRKDIR on failure.
456    pub save_wrkdir_patterns: Vec<String>,
457    /// Environment variables for scan processes.
458    pub scanenv: HashMap<String, String>,
459    /// Path to tar binary.
460    pub tar: PathBuf,
461}
462
463/// Sandbox configuration from the `sandboxes` section.
464///
465/// When this section is present in the configuration, builds are performed
466/// in isolated chroot environments.
467///
468/// # Example
469///
470/// ```lua
471/// sandboxes = {
472///     basedir = "/data/chroot/bob",
473///     actions = {
474///         { action = "mount", fs = "proc", dir = "/proc" },
475///         { action = "copy", dir = "/etc" },
476///     },
477/// }
478/// ```
479#[derive(Clone, Debug, Default)]
480pub struct Sandboxes {
481    /// Base directory for sandbox roots (e.g., `/data/chroot/bob`).
482    ///
483    /// Individual sandboxes are created as numbered subdirectories:
484    /// `basedir/0`, `basedir/1`, etc.
485    pub basedir: PathBuf,
486    /// Actions to perform during sandbox setup/teardown.
487    ///
488    /// See [`Action`] for details.
489    pub actions: Vec<Action>,
490}
491
492impl Config {
493    /// Load configuration from a Lua file.
494    ///
495    /// # Arguments
496    ///
497    /// * `config_path` - Path to configuration file, or `None` to use `./config.lua`
498    /// * `verbose` - Enable verbose output (overrides config file setting)
499    ///
500    /// # Errors
501    ///
502    /// Returns an error if the configuration file doesn't exist or contains
503    /// invalid Lua syntax.
504    pub fn load(config_path: Option<&Path>, verbose: bool) -> Result<Config> {
505        /*
506         * Load user-supplied configuration file, or the default location.
507         */
508        let filename = if let Some(path) = config_path {
509            path.to_path_buf()
510        } else {
511            std::env::current_dir()
512                .context("Unable to determine current directory")?
513                .join("config.lua")
514        };
515
516        /* A configuration file is mandatory. */
517        if !filename.exists() {
518            anyhow::bail!(
519                "Configuration file {} does not exist",
520                filename.display()
521            );
522        }
523
524        /*
525         * Parse configuration file as Lua.
526         */
527        let (mut file, lua_env) =
528            load_lua(&filename).map_err(|e| anyhow!(e)).with_context(|| {
529                format!(
530                    "Unable to parse Lua configuration file {}",
531                    filename.display()
532                )
533            })?;
534
535        /*
536         * Parse scripts section.  Paths are resolved relative to config dir
537         * if not absolute.
538         */
539        let base_dir = filename.parent().unwrap_or_else(|| Path::new("."));
540        let mut newscripts: HashMap<String, PathBuf> = HashMap::new();
541        for (k, v) in &file.scripts {
542            let fullpath =
543                if v.is_relative() { base_dir.join(v) } else { v.clone() };
544            newscripts.insert(k.clone(), fullpath);
545        }
546        file.scripts = newscripts;
547
548        /*
549         * Validate bootstrap path exists if specified.
550         */
551        if let Some(ref bootstrap) = file.pkgsrc.bootstrap {
552            if !bootstrap.exists() {
553                anyhow::bail!(
554                    "pkgsrc.bootstrap file {} does not exist",
555                    bootstrap.display()
556                );
557            }
558        }
559
560        /*
561         * Set verbose from command line option, falling back to config file.
562         */
563        let verbose = if verbose {
564            true
565        } else if let Some(v) = &file.options {
566            v.verbose.unwrap_or(false)
567        } else {
568            false
569        };
570
571        Ok(Config { file, filename, verbose, lua_env })
572    }
573
574    pub fn build_threads(&self) -> usize {
575        if let Some(opts) = &self.file.options {
576            opts.build_threads.unwrap_or(1)
577        } else {
578            1
579        }
580    }
581
582    pub fn scan_threads(&self) -> usize {
583        if let Some(opts) = &self.file.options {
584            opts.scan_threads.unwrap_or(1)
585        } else {
586            1
587        }
588    }
589
590    pub fn strict_scan(&self) -> bool {
591        if let Some(opts) = &self.file.options {
592            opts.strict_scan.unwrap_or(false)
593        } else {
594            false
595        }
596    }
597
598    pub fn script(&self, key: &str) -> Option<&PathBuf> {
599        self.file.scripts.get(key)
600    }
601
602    pub fn make(&self) -> &PathBuf {
603        &self.file.pkgsrc.make
604    }
605
606    pub fn pkgpaths(&self) -> &Option<Vec<PkgPath>> {
607        &self.file.pkgsrc.pkgpaths
608    }
609
610    pub fn pkgsrc(&self) -> &PathBuf {
611        &self.file.pkgsrc.basedir
612    }
613
614    pub fn sandboxes(&self) -> &Option<Sandboxes> {
615        &self.file.sandboxes
616    }
617
618    pub fn verbose(&self) -> bool {
619        self.verbose
620    }
621
622    /// Return the path to the configuration file.
623    pub fn config_path(&self) -> Option<&Path> {
624        if self.filename.as_os_str().is_empty() {
625            None
626        } else {
627            Some(&self.filename)
628        }
629    }
630
631    pub fn logdir(&self) -> &PathBuf {
632        &self.file.pkgsrc.logdir
633    }
634
635    pub fn packages(&self) -> &PathBuf {
636        &self.file.pkgsrc.packages
637    }
638
639    pub fn pkgtools(&self) -> &PathBuf {
640        &self.file.pkgsrc.pkgtools
641    }
642
643    pub fn prefix(&self) -> &PathBuf {
644        &self.file.pkgsrc.prefix
645    }
646
647    #[allow(dead_code)]
648    pub fn report_dir(&self) -> Option<&PathBuf> {
649        self.file.pkgsrc.report_dir.as_ref()
650    }
651
652    pub fn save_wrkdir_patterns(&self) -> &[String] {
653        self.file.pkgsrc.save_wrkdir_patterns.as_slice()
654    }
655
656    pub fn tar(&self) -> &PathBuf {
657        &self.file.pkgsrc.tar
658    }
659
660    pub fn build_user(&self) -> Option<&str> {
661        self.file.pkgsrc.build_user.as_deref()
662    }
663
664    pub fn bootstrap(&self) -> Option<&PathBuf> {
665        self.file.pkgsrc.bootstrap.as_ref()
666    }
667
668    /// Get environment variables for a package from the Lua env function/table.
669    pub fn get_pkg_env(
670        &self,
671        idx: &ResolvedIndex,
672    ) -> Result<std::collections::HashMap<String, String>, String> {
673        self.lua_env.get_env(idx)
674    }
675
676    /// Return environment variables for script execution.
677    pub fn script_env(&self) -> Vec<(String, String)> {
678        let mut envs = vec![
679            ("bob_logdir".to_string(), format!("{}", self.logdir().display())),
680            ("bob_make".to_string(), format!("{}", self.make().display())),
681            (
682                "bob_packages".to_string(),
683                format!("{}", self.packages().display()),
684            ),
685            (
686                "bob_pkgtools".to_string(),
687                format!("{}", self.pkgtools().display()),
688            ),
689            ("bob_pkgsrc".to_string(), format!("{}", self.pkgsrc().display())),
690            ("bob_prefix".to_string(), format!("{}", self.prefix().display())),
691            ("bob_tar".to_string(), format!("{}", self.tar().display())),
692        ];
693        if let Some(build_user) = self.build_user() {
694            envs.push(("bob_build_user".to_string(), build_user.to_string()));
695        }
696        if let Some(bootstrap) = self.bootstrap() {
697            envs.push((
698                "bob_bootstrap".to_string(),
699                format!("{}", bootstrap.display()),
700            ));
701        }
702        envs
703    }
704
705    /// Return environment variables for scan processes.
706    pub fn scan_env(&self) -> Vec<(String, String)> {
707        self.file
708            .pkgsrc
709            .scanenv
710            .iter()
711            .map(|(k, v)| (k.clone(), v.clone()))
712            .collect()
713    }
714
715    /// Validate the configuration, checking that required paths and files exist.
716    pub fn validate(&self) -> Result<(), Vec<String>> {
717        let mut errors: Vec<String> = Vec::new();
718
719        // Check pkgsrc directory exists
720        if !self.file.pkgsrc.basedir.exists() {
721            errors.push(format!(
722                "pkgsrc basedir does not exist: {}",
723                self.file.pkgsrc.basedir.display()
724            ));
725        }
726
727        // Check make binary exists (only on host if sandboxes not enabled)
728        // When sandboxes are enabled, the make binary is inside the sandbox
729        if self.file.sandboxes.is_none() && !self.file.pkgsrc.make.exists() {
730            errors.push(format!(
731                "make binary does not exist: {}",
732                self.file.pkgsrc.make.display()
733            ));
734        }
735
736        // Check scripts exist
737        for (name, path) in &self.file.scripts {
738            if !path.exists() {
739                errors.push(format!(
740                    "Script '{}' does not exist: {}",
741                    name,
742                    path.display()
743                ));
744            } else if !path.is_file() {
745                errors.push(format!(
746                    "Script '{}' is not a file: {}",
747                    name,
748                    path.display()
749                ));
750            }
751        }
752
753        // Check sandbox basedir is writable if sandboxes enabled
754        if let Some(sandboxes) = &self.file.sandboxes {
755            // Check parent directory exists or can be created
756            if let Some(parent) = sandboxes.basedir.parent() {
757                if !parent.exists() {
758                    errors.push(format!(
759                        "Sandbox basedir parent does not exist: {}",
760                        parent.display()
761                    ));
762                }
763            }
764        }
765
766        // Check logdir can be created
767        if let Some(parent) = self.file.pkgsrc.logdir.parent() {
768            if !parent.exists() {
769                errors.push(format!(
770                    "logdir parent directory does not exist: {}",
771                    parent.display()
772                ));
773            }
774        }
775
776        // Check packages dir can be created
777        if let Some(parent) = self.file.pkgsrc.packages.parent() {
778            if !parent.exists() {
779                errors.push(format!(
780                    "Packages parent directory does not exist: {}",
781                    parent.display()
782                ));
783            }
784        }
785
786        if errors.is_empty() { Ok(()) } else { Err(errors) }
787    }
788}
789
790/// Load a Lua configuration file and return a ConfigFile and LuaEnv.
791fn load_lua(filename: &Path) -> Result<(ConfigFile, LuaEnv), String> {
792    let lua = Lua::new();
793
794    // Add config directory to package.path so require() finds relative modules
795    if let Some(config_dir) = filename.parent() {
796        let path_setup = format!(
797            "package.path = '{}' .. '/?.lua;' .. package.path",
798            config_dir.display()
799        );
800        lua.load(&path_setup)
801            .exec()
802            .map_err(|e| format!("Failed to set package.path: {}", e))?;
803    }
804
805    lua.load(filename)
806        .exec()
807        .map_err(|e| format!("Lua execution error: {}", e))?;
808
809    // Get the global table (Lua script should set global variables)
810    let globals = lua.globals();
811
812    // Parse each section
813    let options = parse_options(&globals)
814        .map_err(|e| format!("Error parsing options: {}", e))?;
815    let pkgsrc_table: Table = globals
816        .get("pkgsrc")
817        .map_err(|e| format!("Error getting pkgsrc: {}", e))?;
818    let pkgsrc = parse_pkgsrc(&globals)
819        .map_err(|e| format!("Error parsing pkgsrc: {}", e))?;
820    let scripts = parse_scripts(&globals)
821        .map_err(|e| format!("Error parsing scripts: {}", e))?;
822    let sandboxes = parse_sandboxes(&globals)
823        .map_err(|e| format!("Error parsing sandboxes: {}", e))?;
824
825    // Store env function/table in registry if it exists
826    let env_key = if let Ok(env_value) = pkgsrc_table.get::<Value>("env") {
827        if !env_value.is_nil() {
828            let key = lua.create_registry_value(env_value).map_err(|e| {
829                format!("Failed to store env in registry: {}", e)
830            })?;
831            Some(Arc::new(key))
832        } else {
833            None
834        }
835    } else {
836        None
837    };
838
839    let lua_env = LuaEnv { lua: Arc::new(Mutex::new(lua)), env_key };
840
841    let config = ConfigFile { options, pkgsrc, scripts, sandboxes };
842
843    Ok((config, lua_env))
844}
845
846fn parse_options(globals: &Table) -> LuaResult<Option<Options>> {
847    let options: Value = globals.get("options")?;
848    if options.is_nil() {
849        return Ok(None);
850    }
851
852    let table = options
853        .as_table()
854        .ok_or_else(|| mlua::Error::runtime("options must be a table"))?;
855
856    Ok(Some(Options {
857        build_threads: table.get("build_threads").ok(),
858        scan_threads: table.get("scan_threads").ok(),
859        strict_scan: table.get("strict_scan").ok(),
860        verbose: table.get("verbose").ok(),
861    }))
862}
863
864fn parse_pkgsrc(globals: &Table) -> LuaResult<Pkgsrc> {
865    let pkgsrc: Table = globals.get("pkgsrc")?;
866
867    let basedir: String = pkgsrc.get("basedir")?;
868    let bootstrap: Option<PathBuf> =
869        pkgsrc.get::<Option<String>>("bootstrap")?.map(PathBuf::from);
870    let build_user: Option<String> =
871        pkgsrc.get::<Option<String>>("build_user")?;
872    let logdir: String = pkgsrc.get("logdir")?;
873    let make: String = pkgsrc.get("make")?;
874    let packages: String = pkgsrc.get("packages")?;
875    let pkgtools: String = pkgsrc.get("pkgtools")?;
876    let prefix: String = pkgsrc.get("prefix")?;
877    let tar: String = pkgsrc.get("tar")?;
878
879    let pkgpaths: Option<Vec<PkgPath>> =
880        match pkgsrc.get::<Value>("pkgpaths")? {
881            Value::Nil => None,
882            Value::Table(t) => {
883                let paths: Vec<PkgPath> = t
884                    .sequence_values::<String>()
885                    .filter_map(|r| r.ok())
886                    .filter_map(|s| PkgPath::new(&s).ok())
887                    .collect();
888                if paths.is_empty() { None } else { Some(paths) }
889            }
890            _ => None,
891        };
892
893    let report_dir: Option<PathBuf> =
894        pkgsrc.get::<Option<String>>("report_dir")?.map(PathBuf::from);
895
896    let save_wrkdir_patterns: Vec<String> =
897        match pkgsrc.get::<Value>("save_wrkdir_patterns")? {
898            Value::Nil => Vec::new(),
899            Value::Table(t) => {
900                t.sequence_values::<String>().filter_map(|r| r.ok()).collect()
901            }
902            _ => Vec::new(),
903        };
904
905    let scanenv: HashMap<String, String> =
906        match pkgsrc.get::<Value>("scanenv")? {
907            Value::Nil => HashMap::new(),
908            Value::Table(t) => {
909                t.pairs::<String, String>().filter_map(|r| r.ok()).collect()
910            }
911            _ => HashMap::new(),
912        };
913
914    Ok(Pkgsrc {
915        basedir: PathBuf::from(basedir),
916        bootstrap,
917        build_user,
918        logdir: PathBuf::from(logdir),
919        make: PathBuf::from(make),
920        packages: PathBuf::from(packages),
921        pkgtools: PathBuf::from(pkgtools),
922        pkgpaths,
923        prefix: PathBuf::from(prefix),
924        report_dir,
925        save_wrkdir_patterns,
926        scanenv,
927        tar: PathBuf::from(tar),
928    })
929}
930
931fn parse_scripts(globals: &Table) -> LuaResult<HashMap<String, PathBuf>> {
932    let scripts: Value = globals.get("scripts")?;
933    if scripts.is_nil() {
934        return Ok(HashMap::new());
935    }
936
937    let table = scripts
938        .as_table()
939        .ok_or_else(|| mlua::Error::runtime("scripts must be a table"))?;
940
941    let mut result = HashMap::new();
942    for pair in table.pairs::<String, String>() {
943        let (k, v) = pair?;
944        result.insert(k, PathBuf::from(v));
945    }
946
947    Ok(result)
948}
949
950fn parse_sandboxes(globals: &Table) -> LuaResult<Option<Sandboxes>> {
951    let sandboxes: Value = globals.get("sandboxes")?;
952    if sandboxes.is_nil() {
953        return Ok(None);
954    }
955
956    let table = sandboxes
957        .as_table()
958        .ok_or_else(|| mlua::Error::runtime("sandboxes must be a table"))?;
959
960    let basedir: String = table.get("basedir")?;
961
962    let actions_value: Value = table.get("actions")?;
963    let actions = if actions_value.is_nil() {
964        Vec::new()
965    } else {
966        let actions_table = actions_value.as_table().ok_or_else(|| {
967            mlua::Error::runtime("sandboxes.actions must be a table")
968        })?;
969        parse_actions(actions_table)?
970    };
971
972    Ok(Some(Sandboxes { basedir: PathBuf::from(basedir), actions }))
973}
974
975fn parse_actions(table: &Table) -> LuaResult<Vec<Action>> {
976    table.sequence_values::<Table>().map(|v| Action::from_lua(&v?)).collect()
977}