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