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