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}