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 (required for build, optional for scan).
446    pub packages: Option<PathBuf>,
447    /// Directory containing pkg_add/pkg_delete (required for build, optional for scan).
448    pub pkgtools: Option<PathBuf>,
449    /// List of packages to build.
450    pub pkgpaths: Option<Vec<PkgPath>>,
451    /// Installation prefix (required for build, optional for scan).
452    pub prefix: Option<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 (required for build, optional for scan).
460    pub tar: Option<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) -> Option<&PathBuf> {
636        self.file.pkgsrc.packages.as_ref()
637    }
638
639    pub fn pkgtools(&self) -> Option<&PathBuf> {
640        self.file.pkgsrc.pkgtools.as_ref()
641    }
642
643    pub fn prefix(&self) -> Option<&PathBuf> {
644        self.file.pkgsrc.prefix.as_ref()
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) -> Option<&PathBuf> {
657        self.file.pkgsrc.tar.as_ref()
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            ("bob_pkgsrc".to_string(), format!("{}", self.pkgsrc().display())),
682        ];
683        if let Some(packages) = self.packages() {
684            envs.push((
685                "bob_packages".to_string(),
686                format!("{}", packages.display()),
687            ));
688        }
689        if let Some(pkgtools) = self.pkgtools() {
690            envs.push((
691                "bob_pkgtools".to_string(),
692                format!("{}", pkgtools.display()),
693            ));
694        }
695        if let Some(prefix) = self.prefix() {
696            envs.push((
697                "bob_prefix".to_string(),
698                format!("{}", prefix.display()),
699            ));
700        }
701        if let Some(tar) = self.tar() {
702            envs.push(("bob_tar".to_string(), format!("{}", tar.display())));
703        }
704        if let Some(build_user) = self.build_user() {
705            envs.push(("bob_build_user".to_string(), build_user.to_string()));
706        }
707        if let Some(bootstrap) = self.bootstrap() {
708            envs.push((
709                "bob_bootstrap".to_string(),
710                format!("{}", bootstrap.display()),
711            ));
712        }
713        envs
714    }
715
716    /// Return environment variables for scan processes.
717    pub fn scan_env(&self) -> Vec<(String, String)> {
718        self.file
719            .pkgsrc
720            .scanenv
721            .iter()
722            .map(|(k, v)| (k.clone(), v.clone()))
723            .collect()
724    }
725
726    /// Validate the configuration, checking that required paths and files exist.
727    pub fn validate(&self) -> Result<(), Vec<String>> {
728        let mut errors: Vec<String> = Vec::new();
729
730        // Check pkgsrc directory exists
731        if !self.file.pkgsrc.basedir.exists() {
732            errors.push(format!(
733                "pkgsrc basedir does not exist: {}",
734                self.file.pkgsrc.basedir.display()
735            ));
736        }
737
738        // Check make binary exists (only on host if sandboxes not enabled)
739        // When sandboxes are enabled, the make binary is inside the sandbox
740        if self.file.sandboxes.is_none() && !self.file.pkgsrc.make.exists() {
741            errors.push(format!(
742                "make binary does not exist: {}",
743                self.file.pkgsrc.make.display()
744            ));
745        }
746
747        // Check scripts exist
748        for (name, path) in &self.file.scripts {
749            if !path.exists() {
750                errors.push(format!(
751                    "Script '{}' does not exist: {}",
752                    name,
753                    path.display()
754                ));
755            } else if !path.is_file() {
756                errors.push(format!(
757                    "Script '{}' is not a file: {}",
758                    name,
759                    path.display()
760                ));
761            }
762        }
763
764        // Check sandbox basedir is writable if sandboxes enabled
765        if let Some(sandboxes) = &self.file.sandboxes {
766            // Check parent directory exists or can be created
767            if let Some(parent) = sandboxes.basedir.parent() {
768                if !parent.exists() {
769                    errors.push(format!(
770                        "Sandbox basedir parent does not exist: {}",
771                        parent.display()
772                    ));
773                }
774            }
775        }
776
777        // Check logdir can be created
778        if let Some(parent) = self.file.pkgsrc.logdir.parent() {
779            if !parent.exists() {
780                errors.push(format!(
781                    "logdir parent directory does not exist: {}",
782                    parent.display()
783                ));
784            }
785        }
786
787        // Check packages dir can be created (if configured)
788        if let Some(ref packages) = self.file.pkgsrc.packages {
789            if let Some(parent) = packages.parent() {
790                if !parent.exists() {
791                    errors.push(format!(
792                        "Packages parent directory does not exist: {}",
793                        parent.display()
794                    ));
795                }
796            }
797        }
798
799        if errors.is_empty() { Ok(()) } else { Err(errors) }
800    }
801}
802
803/// Load a Lua configuration file and return a ConfigFile and LuaEnv.
804fn load_lua(filename: &Path) -> Result<(ConfigFile, LuaEnv), String> {
805    let lua = Lua::new();
806
807    // Add config directory to package.path so require() finds relative modules
808    if let Some(config_dir) = filename.parent() {
809        let path_setup = format!(
810            "package.path = '{}' .. '/?.lua;' .. package.path",
811            config_dir.display()
812        );
813        lua.load(&path_setup)
814            .exec()
815            .map_err(|e| format!("Failed to set package.path: {}", e))?;
816    }
817
818    lua.load(filename)
819        .exec()
820        .map_err(|e| format!("Lua execution error: {}", e))?;
821
822    // Get the global table (Lua script should set global variables)
823    let globals = lua.globals();
824
825    // Parse each section
826    let options = parse_options(&globals)
827        .map_err(|e| format!("Error parsing options config: {}", e))?;
828    let pkgsrc_table: Table = globals
829        .get("pkgsrc")
830        .map_err(|e| format!("Error getting pkgsrc config: {}", e))?;
831    let pkgsrc = parse_pkgsrc(&globals)
832        .map_err(|e| format!("Error parsing pkgsrc config: {}", e))?;
833    let scripts = parse_scripts(&globals)
834        .map_err(|e| format!("Error parsing scripts config: {}", e))?;
835    let sandboxes = parse_sandboxes(&globals)
836        .map_err(|e| format!("Error parsing sandboxes config: {}", e))?;
837
838    // Store env function/table in registry if it exists
839    let env_key = if let Ok(env_value) = pkgsrc_table.get::<Value>("env") {
840        if !env_value.is_nil() {
841            let key = lua.create_registry_value(env_value).map_err(|e| {
842                format!("Failed to store env in registry: {}", e)
843            })?;
844            Some(Arc::new(key))
845        } else {
846            None
847        }
848    } else {
849        None
850    };
851
852    let lua_env = LuaEnv { lua: Arc::new(Mutex::new(lua)), env_key };
853
854    let config = ConfigFile { options, pkgsrc, scripts, sandboxes };
855
856    Ok((config, lua_env))
857}
858
859fn parse_options(globals: &Table) -> LuaResult<Option<Options>> {
860    let options: Value = globals.get("options")?;
861    if options.is_nil() {
862        return Ok(None);
863    }
864
865    let table = options
866        .as_table()
867        .ok_or_else(|| mlua::Error::runtime("'options' must be a table"))?;
868
869    const KNOWN_KEYS: &[&str] =
870        &["build_threads", "scan_threads", "strict_scan", "verbose"];
871    warn_unknown_keys(table, "options", KNOWN_KEYS);
872
873    Ok(Some(Options {
874        build_threads: table.get("build_threads").ok(),
875        scan_threads: table.get("scan_threads").ok(),
876        strict_scan: table.get("strict_scan").ok(),
877        verbose: table.get("verbose").ok(),
878    }))
879}
880
881/// Warn about unknown keys in a Lua table.
882fn warn_unknown_keys(table: &Table, table_name: &str, known_keys: &[&str]) {
883    for (key, _) in table.pairs::<String, Value>().flatten() {
884        if !known_keys.contains(&key.as_str()) {
885            eprintln!("Warning: unknown config key '{}.{}'", table_name, key);
886        }
887    }
888}
889
890fn get_required_string(table: &Table, field: &str) -> LuaResult<String> {
891    let value: Value = table.get(field)?;
892    match value {
893        Value::String(s) => Ok(s.to_str()?.to_string()),
894        Value::Integer(n) => Ok(n.to_string()),
895        Value::Number(n) => Ok(n.to_string()),
896        Value::Nil => Err(mlua::Error::runtime(format!(
897            "missing required field '{}'",
898            field
899        ))),
900        _ => Err(mlua::Error::runtime(format!(
901            "field '{}' must be a string, got {}",
902            field,
903            value.type_name()
904        ))),
905    }
906}
907
908fn parse_pkgsrc(globals: &Table) -> LuaResult<Pkgsrc> {
909    let pkgsrc: Table = globals.get("pkgsrc")?;
910
911    const KNOWN_KEYS: &[&str] = &[
912        "basedir",
913        "bootstrap",
914        "build_user",
915        "env",
916        "logdir",
917        "make",
918        "packages",
919        "pkgpaths",
920        "pkgtools",
921        "prefix",
922        "report_dir",
923        "save_wrkdir_patterns",
924        "scanenv",
925        "tar",
926    ];
927    warn_unknown_keys(&pkgsrc, "pkgsrc", KNOWN_KEYS);
928
929    let basedir = get_required_string(&pkgsrc, "basedir")?;
930    let bootstrap: Option<PathBuf> =
931        pkgsrc.get::<Option<String>>("bootstrap")?.map(PathBuf::from);
932    let build_user: Option<String> =
933        pkgsrc.get::<Option<String>>("build_user")?;
934    let logdir = get_required_string(&pkgsrc, "logdir")?;
935    let make = get_required_string(&pkgsrc, "make")?;
936    let packages: Option<PathBuf> =
937        pkgsrc.get::<Option<String>>("packages")?.map(PathBuf::from);
938    let pkgtools: Option<PathBuf> =
939        pkgsrc.get::<Option<String>>("pkgtools")?.map(PathBuf::from);
940    let prefix: Option<PathBuf> =
941        pkgsrc.get::<Option<String>>("prefix")?.map(PathBuf::from);
942    let tar: Option<PathBuf> =
943        pkgsrc.get::<Option<String>>("tar")?.map(PathBuf::from);
944
945    let pkgpaths: Option<Vec<PkgPath>> =
946        match pkgsrc.get::<Value>("pkgpaths")? {
947            Value::Nil => None,
948            Value::Table(t) => {
949                let paths: Vec<PkgPath> = t
950                    .sequence_values::<String>()
951                    .filter_map(|r| r.ok())
952                    .filter_map(|s| PkgPath::new(&s).ok())
953                    .collect();
954                if paths.is_empty() { None } else { Some(paths) }
955            }
956            _ => None,
957        };
958
959    let report_dir: Option<PathBuf> =
960        pkgsrc.get::<Option<String>>("report_dir")?.map(PathBuf::from);
961
962    let save_wrkdir_patterns: Vec<String> =
963        match pkgsrc.get::<Value>("save_wrkdir_patterns")? {
964            Value::Nil => Vec::new(),
965            Value::Table(t) => {
966                t.sequence_values::<String>().filter_map(|r| r.ok()).collect()
967            }
968            _ => Vec::new(),
969        };
970
971    let scanenv: HashMap<String, String> =
972        match pkgsrc.get::<Value>("scanenv")? {
973            Value::Nil => HashMap::new(),
974            Value::Table(t) => {
975                t.pairs::<String, String>().filter_map(|r| r.ok()).collect()
976            }
977            _ => HashMap::new(),
978        };
979
980    Ok(Pkgsrc {
981        basedir: PathBuf::from(basedir),
982        bootstrap,
983        build_user,
984        logdir: PathBuf::from(logdir),
985        make: PathBuf::from(make),
986        packages,
987        pkgtools,
988        pkgpaths,
989        prefix,
990        report_dir,
991        save_wrkdir_patterns,
992        scanenv,
993        tar,
994    })
995}
996
997fn parse_scripts(globals: &Table) -> LuaResult<HashMap<String, PathBuf>> {
998    let scripts: Value = globals.get("scripts")?;
999    if scripts.is_nil() {
1000        return Ok(HashMap::new());
1001    }
1002
1003    let table = scripts
1004        .as_table()
1005        .ok_or_else(|| mlua::Error::runtime("'scripts' must be a table"))?;
1006
1007    let mut result = HashMap::new();
1008    for pair in table.pairs::<String, String>() {
1009        let (k, v) = pair?;
1010        result.insert(k, PathBuf::from(v));
1011    }
1012
1013    Ok(result)
1014}
1015
1016fn parse_sandboxes(globals: &Table) -> LuaResult<Option<Sandboxes>> {
1017    let sandboxes: Value = globals.get("sandboxes")?;
1018    if sandboxes.is_nil() {
1019        return Ok(None);
1020    }
1021
1022    let table = sandboxes
1023        .as_table()
1024        .ok_or_else(|| mlua::Error::runtime("'sandboxes' must be a table"))?;
1025
1026    const KNOWN_KEYS: &[&str] = &["actions", "basedir"];
1027    warn_unknown_keys(table, "sandboxes", KNOWN_KEYS);
1028
1029    let basedir: String = table.get("basedir")?;
1030
1031    let actions_value: Value = table.get("actions")?;
1032    let actions = if actions_value.is_nil() {
1033        Vec::new()
1034    } else {
1035        let actions_table = actions_value.as_table().ok_or_else(|| {
1036            mlua::Error::runtime("'sandboxes.actions' must be a table")
1037        })?;
1038        parse_actions(actions_table)?
1039    };
1040
1041    Ok(Some(Sandboxes { basedir: PathBuf::from(basedir), actions }))
1042}
1043
1044fn parse_actions(table: &Table) -> LuaResult<Vec<Action>> {
1045    table.sequence_values::<Table>().map(|v| Action::from_lua(&v?)).collect()
1046}