Skip to main content

bob/
config.rs

1/*
2 * Copyright (c) 2026 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 per-build actions.
21//!
22//! # Configuration File Structure
23//!
24//! A configuration file has five main sections:
25//!
26//! - [`options`](#options-section) - General build options (optional)
27//! - [`pkgsrc`](#pkgsrc-section) - pkgsrc paths and package list (required)
28//! - [`sandboxes`](#sandboxes-section) - Sandbox configuration (optional)
29//! - [`dynamic`](#dynamic-section) - Dynamic resource allocation (optional)
30//! - [`publish`](#publish-section) - Remote publishing configuration (optional)
31//!
32//! # Options Section
33//!
34//! The `options` section is optional. All fields have defaults.
35//!
36//! | Field | Type | Default | Description |
37//! |-------|------|---------|-------------|
38//! | `build_threads` | integer | 1 | Number of parallel build sandboxes. Each sandbox builds one package at a time. |
39//! | `dbdir` | string | "./db" | Directory for bob state files (database, tracing log). Relative to config file directory. |
40//! | `logdir` | string | `dbdir/logs` | Directory for per-package build logs. Failed builds leave logs here; successful builds clean up. |
41//! | `scan_threads` | integer | 1 | Number of parallel scan processes for dependency discovery. |
42//! | `strict_scan` | boolean | false | If true, abort on scan errors. If false, continue and report failures separately. |
43//! | `log_level` | string | "info" | Log level: "trace", "debug", "info", "warn", or "error". Can be overridden by `RUST_LOG` env var. |
44//!
45//! # Pkgsrc Section
46//!
47//! The `pkgsrc` section is required and defines paths to pkgsrc components.
48//!
49//! ## Required Fields
50//!
51//! | Field | Type | Description |
52//! |-------|------|-------------|
53//! | `basedir` | string | Absolute path to the pkgsrc source tree (e.g., `/data/pkgsrc`). |
54//! | `make` | string | Absolute path to the bmake binary (e.g., `/usr/pkg/bin/bmake`). |
55//!
56//! ## Optional Fields
57//!
58//! | Field | Type | Default | Description |
59//! |-------|------|---------|-------------|
60//! | `bootstrap` | string | none | Path to a bootstrap tarball. Required on non-NetBSD systems. Unpacked into each sandbox before builds. |
61//! | `build_user` | string | none | Unprivileged user to run builds as. If set, builds run as this user instead of root. |
62//! | `cachevars` | table | (OS-specific) | List of pkgsrc variable names to fetch once and cache. These are set in the environment for scans and builds. If set, replaces the built-in defaults. |
63//! | `pkgpaths` | table | `{}` | List of package paths to build (e.g., `{"mail/mutt", "www/curl"}`). Dependencies are discovered automatically. |
64//! | `save_wrkdir_patterns` | table | `{}` | Glob patterns for files to preserve from WRKDIR on build failure (e.g., `{"**/config.log"}`). |
65//!
66//! Per-package make variables should be set in pkgsrc's `mk.conf`, not in
67//! bob.  Bob does not provide a per-package environment override mechanism.
68//!
69//! # Sandboxes Section
70//!
71//! The `sandboxes` section is optional. When present, builds run in isolated
72//! chroot environments.
73//!
74//! | Field | Type | Required | Description |
75//! |-------|------|----------|-------------|
76//! | `basedir` | string | yes | Base directory for sandbox roots. Sandboxes are created as numbered subdirectories (`basedir/0`, `basedir/1`, etc.). |
77//! | `setup` | table | no | Actions to perform during sandbox creation and destruction. See the [`action`](crate::action) module for details. |
78//! | `hooks` | table | no | Per-package hook actions. Any "create" action runs after bob's internal pre-build (unpacks bootstrap kit if needed); any "destroy" action runs before bob's internal post-build (wipes PREFIX and PKG_DBDIR). |
79//! | `environment` | table | no | Environment variables for sandbox processes. If omitted, the parent environment is inherited unchanged. See [Environment](#environment). |
80//!
81//! ## Environment
82//!
83//! Controls how environment variables are set for processes running inside
84//! sandboxes.  When this section is omitted, sandbox processes inherit bob's
85//! parent environment unchanged.
86//!
87//! `environment` contains two independent sub-tables, `build` and `dev`,
88//! one for each context bob runs processes in.  They have an identical
89//! shape (`clear`, `inherit`, `vars`) but are configured separately so
90//! that interactive development conveniences cannot leak into automated
91//! builds.  Either sub-table can be omitted; an omitted context inherits
92//! bob's parent environment unchanged.
93//!
94//! - `build` governs per-package build commands and bob's own pkgsrc-querying
95//!   invocations (`PkgsrcEnv` fetch, `make show-var`, `make show-vars`) when
96//!   the sandbox is in build context.  Values are passed directly to each
97//!   process as literal strings; no shell ever evaluates them.  This context
98//!   typically wants a strict, minimal environment for build reproducibility.
99//!
100//! - `dev` is used by interactive `bob dev` sessions.  Bob writes its `vars`
101//!   verbatim into a small init script (`<sandbox>/.bob/shell-init`) that the
102//!   chrooted shell runs at startup, one `export NAME=value` line per entry,
103//!   so values can reference `bob_*` variables or other shell variables --
104//!   for example `PATH = "${bob_prefix}/bin:..."`.  Each value is emitted
105//!   verbatim, so what you write must be a valid shell assignment right-hand
106//!   side; values containing whitespace or shell metacharacters need to be
107//!   quoted by the user.  `vars` are not set on commands directly -- only on
108//!   the interactive shell -- but the context's `clear`/`inherit` policy
109//!   still applies to the pkgsrc-querying invocations listed above when the
110//!   sandbox is in dev context.  This context typically wants a more generous
111//!   `inherit` list (e.g. `EDITOR`, `PAGER`, locale variables) than `build`,
112//!   since interactive sessions benefit from the developer's normal
113//!   environment.  See the [`action`](crate::action) module for the full
114//!   list of `bob_*` variables.
115//!
116//! Sandbox setup actions and per-package pre/post-build hook actions do not
117//! apply either context's policy.  They inherit bob's parent environment
118//! unchanged, plus the `bob_*` script env and any per-action `env = { ... }`
119//! additions.
120//!
121//! Each `build`/`dev` sub-table has the following fields:
122//!
123//! | Field | Type | Default | Description |
124//! |-------|------|---------|-------------|
125//! | `clear` | boolean | `true` | Start each sandbox process with an empty environment.  Set to `false` to inherit bob's full parent environment instead. |
126//! | `inherit` | table | `{}` | When `clear` is `true`, names of variables to copy from bob's parent environment. |
127//! | `vars` | table | `{}` | Variables to set in this context.  In `build` these are literal strings; in `dev` they are written verbatim into the init script. |
128//!
129//! The `dev` sub-table additionally accepts:
130//!
131//! | Field | Type | Default | Description |
132//! |-------|------|---------|-------------|
133//! | `shell` | string | `/bin/sh` | Path to the interactive shell binary used for the dev session.  The path is resolved inside the sandbox chroot, so the binary must exist there (typically arranged by a `setup` action that mounts or copies it). |
134
135use crate::action::Action;
136use crate::sandbox::Sandbox;
137use anyhow::{Context, Result, anyhow, bail};
138use mlua::{Lua, Result as LuaResult, Table, Value};
139use pkgsrc::PkgPath;
140use std::collections::HashMap;
141use std::ffi::{CStr, CString};
142use std::path::{Path, PathBuf};
143
144/// Environment variables retrieved from pkgsrc.
145///
146/// These values are queried from pkgsrc's mk.conf via bmake and represent
147/// the actual paths pkgsrc is configured to use. This struct is created
148/// after sandbox setup and passed to build operations.
149#[derive(Clone, Debug)]
150pub struct PkgsrcEnv {
151    /// PACKAGES directory for binary packages.
152    pub packages: PathBuf,
153    /// PKG_TOOLS_BIN directory containing pkg_add, pkg_delete, etc.
154    pub pkgtools: PathBuf,
155    /// PREFIX installation directory.
156    pub prefix: PathBuf,
157    /// PKG_DBDIR for installed package database.
158    pub pkg_dbdir: PathBuf,
159    /// PKG_REFCOUNT_DBDIR for refcounted files database.
160    pub pkg_refcount_dbdir: PathBuf,
161    /// Platform and build metadata from pkgsrc (non-empty values only).
162    pub metadata: HashMap<String, String>,
163    /// Cached pkgsrc variables from the `cachevars` config option.
164    pub cachevars: HashMap<String, String>,
165}
166
167impl PkgsrcEnv {
168    /// Fetch pkgsrc environment variables by querying bmake.
169    ///
170    /// This must be called after sandbox 0 is created if sandboxes are enabled,
171    /// since bmake may only exist inside the sandbox.
172    pub fn fetch(config: &Config, sandbox: &Sandbox, id: Option<usize>) -> Result<Self> {
173        const REQUIRED_VARS: &[&str] = &[
174            "PACKAGES",
175            "PKG_DBDIR",
176            "PKG_REFCOUNT_DBDIR",
177            "PKG_TOOLS_BIN",
178            "PREFIX",
179        ];
180
181        const METADATA_VARS: &[&str] = &[
182            "ABI",
183            "CC_VERSION",
184            "LOWER_VARIANT_VERSION",
185            "MACHINE_ARCH",
186            "OPSYS",
187            "OS_VARIANT",
188            "OS_VERSION",
189            "PKGINFODIR",
190            "PKGMANDIR",
191            "PKGSRC_COMPILER",
192            "SYSCONFBASE",
193            "VARBASE",
194        ];
195
196        let cachevar_names: Vec<&str> = if !config.cachevars().is_empty() {
197            config.cachevars().iter().map(|s| s.as_str()).collect()
198        } else {
199            let mut v = vec!["NATIVE_OPSYS", "NATIVE_OPSYS_VERSION", "NATIVE_OS_VERSION"];
200            if cfg!(target_os = "netbsd") {
201                v.push("HOST_MACHINE_ARCH");
202            }
203            if cfg!(any(target_os = "illumos", target_os = "solaris")) {
204                v.push("_UNAME_V");
205            }
206            v
207        };
208
209        let mut all_varnames: Vec<&str> = REQUIRED_VARS.to_vec();
210        all_varnames.extend_from_slice(METADATA_VARS);
211        all_varnames.extend_from_slice(&cachevar_names);
212
213        let varnames_arg = all_varnames.join(" ");
214        let script = format!(
215            "cd {}/pkgtools/pkg_install && {} show-vars VARNAMES=\"{}\"\n",
216            config.pkgsrc().display(),
217            config.make().display(),
218            varnames_arg
219        );
220
221        let child = sandbox.execute_script(id, &script, vec![])?;
222        let output = child
223            .wait_with_output()
224            .context("Failed to execute bmake show-vars")?;
225
226        if !output.status.success() {
227            let stderr = String::from_utf8_lossy(&output.stderr);
228            bail!("Failed to query pkgsrc variables: {}", stderr.trim());
229        }
230
231        let stdout = String::from_utf8_lossy(&output.stdout);
232        let lines: Vec<&str> = stdout.lines().collect();
233
234        if lines.len() != all_varnames.len() {
235            bail!(
236                "Expected {} variables from pkgsrc, got {}",
237                all_varnames.len(),
238                lines.len()
239            );
240        }
241
242        let mut values: HashMap<&str, &str> = HashMap::new();
243        for (varname, value) in all_varnames.iter().zip(&lines) {
244            values.insert(varname, value);
245        }
246
247        for varname in REQUIRED_VARS {
248            if values.get(varname).is_none_or(|v| v.is_empty()) {
249                bail!("pkgsrc returned empty value for {}", varname);
250            }
251        }
252
253        let mut metadata: HashMap<String, String> = HashMap::new();
254        for varname in METADATA_VARS {
255            if let Some(value) = values.get(varname) {
256                if !value.is_empty() {
257                    metadata.insert((*varname).to_string(), (*value).to_string());
258                }
259            }
260        }
261
262        let mut cachevars: HashMap<String, String> = HashMap::new();
263        for varname in &cachevar_names {
264            if let Some(value) = values.get(varname) {
265                if !value.is_empty() {
266                    cachevars.insert((*varname).to_string(), (*value).to_string());
267                }
268            }
269        }
270
271        Ok(PkgsrcEnv {
272            packages: PathBuf::from(values["PACKAGES"]),
273            pkgtools: PathBuf::from(values["PKG_TOOLS_BIN"]),
274            prefix: PathBuf::from(values["PREFIX"]),
275            pkg_dbdir: PathBuf::from(values["PKG_DBDIR"]),
276            pkg_refcount_dbdir: PathBuf::from(values["PKG_REFCOUNT_DBDIR"]),
277            metadata,
278            cachevars,
279        })
280    }
281
282    /// Derive the platform string from metadata variables.
283    ///
284    /// Uses OS_VARIANT if available (e.g., "SmartOS 20241212T000748Z/x86_64"),
285    /// otherwise falls back to OPSYS (e.g., "NetBSD 10.1/x86_64").
286    /// Returns None if the required variables are not available.
287    pub fn platform(&self) -> Option<String> {
288        let arch = self.metadata.get("MACHINE_ARCH")?;
289        if let (Some(variant), Some(version)) = (
290            self.metadata.get("OS_VARIANT"),
291            self.metadata.get("LOWER_VARIANT_VERSION"),
292        ) {
293            Some(format!("{} {}/{}", variant, version, arch))
294        } else {
295            let opsys = self.metadata.get("OPSYS")?;
296            let version = self.metadata.get("OS_VERSION")?;
297            Some(format!("{} {}/{}", opsys, version, arch))
298        }
299    }
300}
301
302/// Main configuration structure.
303#[derive(Clone, Debug, Default)]
304pub struct Config {
305    file: ConfigFile,
306    dbdir: PathBuf,
307    logdir: PathBuf,
308    log_level: String,
309}
310
311/// Parsed configuration file contents.
312#[derive(Clone, Debug, Default)]
313pub struct ConfigFile {
314    /// The `options` section.
315    pub options: Option<Options>,
316    /// The `pkgsrc` section.
317    pub pkgsrc: Pkgsrc,
318    /// The `sandboxes` section.
319    pub sandboxes: Option<Sandboxes>,
320    /// The `dynamic` section.
321    pub dynamic: Option<DynamicConfig>,
322    /// The `publish` section.
323    pub publish: Option<Publish>,
324    /// The `summary` section.
325    pub summary: Summary,
326}
327
328/// General build options from the `options` section.
329///
330/// All fields are optional; defaults are used when not specified:
331/// - `build_threads`: 1
332/// - `scan_threads`: 1
333/// - `log_level`: "info"
334#[derive(Clone, Debug, Default)]
335pub struct Options {
336    /// Number of parallel build sandboxes.
337    pub build_threads: Option<usize>,
338    /// Directory for bob state files (database, tracing log).
339    pub dbdir: Option<PathBuf>,
340    /// Directory for build logs (defaults to `dbdir/logs`).
341    pub logdir: Option<PathBuf>,
342    /// Number of parallel scan processes.
343    pub scan_threads: Option<usize>,
344    /// If true, abort on scan errors. If false, continue and report failures.
345    pub strict_scan: Option<bool>,
346    /// Log level: "trace", "debug", "info", "warn", or "error".
347    pub log_level: Option<String>,
348    /// Enable TUI progress display (default: true). Set to false for plain output.
349    pub tui: Option<bool>,
350}
351
352/**
353 * pkg_summary generation options from the `summary` section.
354 *
355 * Controls what `bob` writes to `PACKAGES/All/pkg_summary.{gz,zst}`
356 * after a build:
357 *
358 * - `include_restricted`: by default, packages with `NO_BIN_ON_FTP`
359 *   set are excluded so the published index advertises only
360 *   redistributable packages.  Set to true on private/internal
361 *   mirrors where redistribution restrictions do not apply.
362 * - `file_cksum`: include a `FILE_CKSUM` entry (SHA256) for each
363 *   package so clients can verify integrity at install time.
364 *   Off by default since it requires re-reading every package file.
365 * - `compression`: which compressed forms to write.  Valid values
366 *   are `gz` and `zst`.  Both are written by default.
367 */
368#[derive(Clone, Debug)]
369pub struct Summary {
370    /// Include packages with NO_BIN_ON_FTP set.
371    pub include_restricted: bool,
372    /// Compute and include FILE_CKSUM for each package.
373    pub file_cksum: bool,
374    /// Compression formats to emit (subset of "gz", "zst").
375    pub compression: Vec<String>,
376}
377
378impl Default for Summary {
379    fn default() -> Self {
380        Self {
381            include_restricted: false,
382            file_cksum: false,
383            compression: vec!["gz".to_string(), "zst".to_string()],
384        }
385    }
386}
387
388/// Dynamic resource allocation from the `dynamic` section.
389///
390/// Controls dynamic CPU and disk allocation informed by build history.
391///
392/// - `jobs`: Total MAKE_JOBS budget to distribute across concurrent builds.
393///   Set this to the number of available CPU threads.  The allocator will
394///   slightly over-allocate to ensure optimal throughput during serial
395///   build phases.
396/// - `wrkobjdir`: Optional automatic WRKOBJDIR selection based on historical
397///   disk usage, routing large builds to disk and small builds to tmpfs.
398#[derive(Clone, Debug)]
399pub struct DynamicConfig {
400    /// Total MAKE_JOBS budget.
401    pub jobs: Option<usize>,
402    /// Optional WRKOBJDIR routing based on historical disk usage.
403    pub wrkobjdir: Option<WrkObjDir>,
404}
405
406/// WRKOBJDIR routing configuration.
407///
408/// When both `tmpfs` and `disk` are set with a `threshold`, packages
409/// whose historical disk usage exceeds `threshold` build in `disk`
410/// and everything else builds in `tmpfs`.  When only one path is set,
411/// all builds use that path.
412#[derive(Clone, Debug)]
413pub struct WrkObjDir {
414    /// Fast (tmpfs) WRKOBJDIR for builds under threshold.
415    pub tmpfs: Option<PathBuf>,
416    /// Disk-backed WRKOBJDIR for large builds.
417    pub disk: Option<PathBuf>,
418    /// Size threshold in bytes for routing between tmpfs and disk.
419    pub threshold: Option<u64>,
420    /// Size threshold in bytes for routing failed builds.  When set,
421    /// a previously-failed package whose recorded disk usage is at or
422    /// below this value is routed to tmpfs; above it (or with no
423    /// history) goes to disk.  Should be well below `threshold` to
424    /// account for temporary build artifacts not captured by du.
425    /// When `None`, all failed builds default to disk.
426    pub failed_threshold: Option<u64>,
427    /// Pkgpaths whose builds must always use the disk WRKOBJDIR,
428    /// regardless of historical disk usage.  Escape hatch for
429    /// packages whose true peak disk usage is much larger than the
430    /// post-build measurement (e.g. wheel builds that materialize
431    /// the install tree twice before deleting one copy).  Exact
432    /// match against the package's pkgpath (e.g. `sysutils/ansible`).
433    pub always_disk: Vec<String>,
434}
435
436impl WrkObjDir {
437    /// Route a package to tmpfs or disk based on historical disk usage.
438    pub fn route(&self, disk_usage: Option<u64>) -> Option<WrkObjKind> {
439        match (&self.tmpfs, &self.disk, self.threshold) {
440            (Some(tmpfs), Some(disk), Some(threshold)) => match disk_usage {
441                Some(size) if size <= threshold => Some(WrkObjKind::Tmpfs(tmpfs.clone())),
442                _ => Some(WrkObjKind::Disk(disk.clone())),
443            },
444            (Some(dir), None, _) => Some(WrkObjKind::Tmpfs(dir.clone())),
445            (None, Some(dir), _) => Some(WrkObjKind::Disk(dir.clone())),
446            _ => None,
447        }
448    }
449}
450
451/**
452 * A resolved WRKOBJDIR assignment for a single package.
453 */
454#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, strum::Display, strum::EnumString)]
455#[strum(serialize_all = "snake_case")]
456pub enum WrkObjKind {
457    Tmpfs(PathBuf),
458    Disk(PathBuf),
459}
460
461impl WrkObjKind {
462    pub fn path(&self) -> &Path {
463        match self {
464            Self::Tmpfs(p) | Self::Disk(p) => p,
465        }
466    }
467}
468
469/// Publishing configuration from the `publish` section.
470///
471/// Controls how binary packages and reports are published to remote servers.
472/// Each sub-section configures its own rsync arguments since the appropriate
473/// defaults differ: binary packages are already compressed and don't benefit
474/// from rsync's `-z`, while text-heavy report directories do.
475#[derive(Clone, Debug)]
476pub struct Publish {
477    /// Path to rsync binary (default: "rsync").
478    pub rsync: PathBuf,
479    /// Package publishing configuration.
480    pub packages: Option<PublishPackages>,
481    /// Report publishing configuration.
482    pub report: Option<PublishReport>,
483}
484
485/// Package publishing configuration.
486///
487/// Supports two modes:
488///
489/// - **Direct**: `tmppath` is unset.  rsync writes straight to `path`.
490/// - **Atomic**: `tmppath` is set.  rsync writes to `tmppath` with
491///   `--link-dest=path` (unchanged files become hardlinks), then a
492///   shell script (`swapcmd`) atomically swaps `tmppath` into `path`.
493///
494/// Restricted packages (NO_BIN_ON_FTP) are automatically excluded.
495#[derive(Clone, Debug)]
496pub struct PublishPackages {
497    /// Remote hostname.
498    pub host: String,
499    /// Remote user (if unset, relies on ssh config).
500    pub user: Option<String>,
501    /// Remote path to the live published directory.
502    pub path: String,
503    /// Optional remote path for staging during sync.  If set, enables
504    /// atomic-swap mode: rsync writes here with `--link-dest=path`,
505    /// then `swapcmd` moves it into place.
506    pub tmppath: Option<String>,
507    /// Optional shell script run via ssh on the remote host after rsync
508    /// completes.  Only meaningful when `tmppath` is set.  Either a
509    /// literal string or a [`ScriptValue`] bundling the script with
510    /// environment variables.
511    pub swapcmd: Option<ScriptValue>,
512    /// Minimum successful package count required before publishing.
513    pub minimum: Option<usize>,
514    /// Glob patterns that must match at least one successful package.
515    pub required: Vec<String>,
516    /// rsync arguments for package publishing.  Default
517    /// `"-av --delete-excluded -e ssh"`: no `-z` since binary packages
518    /// are already compressed.
519    pub rsync_args: String,
520}
521
522/// Report publishing configuration.
523#[derive(Clone, Debug)]
524pub struct PublishReport {
525    /// Remote hostname.
526    pub host: String,
527    /// Remote user (if unset, relies on ssh config).
528    pub user: Option<String>,
529    /// Remote directory path for report upload.
530    pub path: String,
531    /// Public URL where the report is accessible.
532    pub url: Option<String>,
533    /// rsync arguments for report publishing.  Default
534    /// `"-avz --delete-excluded -e ssh"`: includes `-z` since reports
535    /// are mostly text and benefit from compression.
536    pub rsync_args: String,
537    /// Override auto-detected branch name for reports and email.
538    pub branch: Option<String>,
539    /// Email sender in "Name <addr>" format.
540    pub from: Option<String>,
541    /// Email recipients.
542    pub to: Vec<String>,
543}
544
545/// pkgsrc-related configuration from the `pkgsrc` section.
546///
547/// # Required Fields
548///
549/// - `basedir`: Path to pkgsrc source tree
550/// - `make`: Path to bmake binary
551///
552/// # Optional Fields
553///
554/// - `bootstrap`: Path to bootstrap tarball (required on non-NetBSD systems)
555/// - `build_user`: Unprivileged user for builds
556/// - `pkgpaths`: List of packages to build
557/// - `save_wrkdir_patterns`: Glob patterns for files to save on build failure
558#[derive(Clone, Debug, Default)]
559pub struct Pkgsrc {
560    /// Path to pkgsrc source tree.
561    pub basedir: PathBuf,
562    /// Path to bootstrap tarball (required on non-NetBSD).
563    pub bootstrap: Option<PathBuf>,
564    /// Unprivileged user for builds.
565    pub build_user: Option<String>,
566    /// Home directory of build_user (resolved from password database).
567    pub build_user_home: Option<PathBuf>,
568    /// Path to bmake binary.
569    pub make: PathBuf,
570    /// List of packages to build.
571    pub pkgpaths: Option<Vec<PkgPath>>,
572    /// Glob patterns for files to save from WRKDIR on failure.
573    pub save_wrkdir_patterns: Vec<String>,
574    /// pkgsrc variables to cache and re-set in each environment run.
575    pub cachevars: Vec<String>,
576}
577
578/// Environment configuration from `sandboxes.environment`.
579///
580/// Wraps two independent per-context configurations: `build` (for every
581/// operation driven by `bob build`) and `dev` (for interactive sandbox
582/// sessions used during pkgsrc development).  See the module-level
583/// documentation for the full description.
584///
585/// Either context can be `None`, meaning bob's parent environment is
586/// inherited unchanged for that context.
587#[derive(Clone, Debug, Default)]
588pub struct Environment {
589    /// Build-time environment context.  When `None`, bob's parent
590    /// environment is inherited unchanged for build operations.
591    pub build: Option<EnvContext>,
592    /// Interactive (dev) environment context.  When `None`, bob's
593    /// parent environment is inherited unchanged for the interactive
594    /// session.
595    pub dev: Option<EnvContext>,
596}
597
598/// A single environment context (`environment.build` or `environment.dev`).
599///
600/// Each context has its own `clear`/`inherit`/`vars` policy so that the
601/// build and dev contexts can be configured independently.
602#[derive(Clone, Debug)]
603pub struct EnvContext {
604    /// Whether to start processes in this context with an empty
605    /// environment.  Defaults to `true`.  When `false`, bob's full
606    /// parent environment is inherited instead.
607    pub clear: bool,
608    /// When `clear` is `true`, names of variables to copy from bob's
609    /// parent environment.
610    pub inherit: Vec<String>,
611    /// Variables to set in this context.  For `build`, values are
612    /// literal strings.  For `dev`, values are written verbatim into
613    /// the wrapper init script so they can reference `bob_*` and other
614    /// shell variables but must be quoted by the user if they contain
615    /// whitespace or shell metacharacters.
616    pub vars: HashMap<String, String>,
617    /// Path to the interactive shell binary for the dev sandbox
618    /// session.  Only meaningful in `environment.dev`; ignored in
619    /// `environment.build`.  Defaults to `/bin/sh`.  The path is
620    /// resolved inside the sandbox chroot.
621    pub shell: Option<PathBuf>,
622}
623
624impl Default for EnvContext {
625    fn default() -> Self {
626        Self {
627            clear: true,
628            inherit: Vec::new(),
629            vars: HashMap::new(),
630            shell: None,
631        }
632    }
633}
634
635/// Sandbox configuration from the `sandboxes` section.
636///
637/// When this section is present in the configuration, builds are performed
638/// in isolated chroot environments.
639///
640/// # Example
641///
642/// ```lua
643/// sandboxes = {
644///     basedir = "/data/chroot",
645///     setup = {
646///         { action = "mount", fs = "proc", dir = "/proc" },
647///         { action = "copy", dir = "/etc" },
648///     },
649/// }
650/// ```
651#[derive(Clone, Debug, Default)]
652pub struct Sandboxes {
653    /// Base directory for sandbox roots (e.g., `/data/chroot`).
654    ///
655    /// Individual sandboxes are created as numbered subdirectories:
656    /// `basedir/0`, `basedir/1`, etc.
657    pub basedir: PathBuf,
658    /// Actions to perform during sandbox creation and destruction.
659    pub setup: Vec<Action>,
660    /**
661     * Per-package hook actions.  Any "create" action runs after bob's
662     * internal pre-build (unpacks bootstrap kit if needed); any "destroy"
663     * action runs before bob's internal post-build (wipes PREFIX and
664     * PKG_DBDIR).
665     */
666    pub hooks: Vec<Action>,
667    /// Environment variables for sandbox processes.
668    pub environment: Option<Environment>,
669    /// Path to bindfs binary (defaults to "bindfs").
670    pub bindfs: String,
671}
672
673impl Config {
674    /// Load configuration from a Lua file.
675    ///
676    /// # Arguments
677    ///
678    /// * `config_path` - Path to configuration file, or `None` to use `./config.lua`
679    ///
680    /// # Errors
681    ///
682    /// Returns an error if the configuration file doesn't exist or contains
683    /// invalid Lua syntax.
684    pub fn load(config_path: Option<&Path>) -> Result<Config> {
685        let filename = match config_path {
686            Some(path) => {
687                if path.is_relative() {
688                    std::env::current_dir()
689                        .context("Unable to determine current directory")?
690                        .join(path)
691                } else {
692                    path.to_path_buf()
693                }
694            }
695            None => default_config_path()?,
696        };
697
698        if !filename.exists() {
699            anyhow::bail!(
700                "Configuration file {} does not exist.\n\
701                 Run 'bob init' to create a default configuration.",
702                filename.display()
703            );
704        }
705
706        /*
707         * Parse configuration file as Lua.
708         */
709        let file = load_lua(&filename)
710            .map_err(|e| anyhow!(e))
711            .with_context(|| {
712                format!(
713                    "Unable to parse Lua configuration file {}",
714                    filename.display()
715                )
716            })?;
717
718        let base_dir = filename.parent().unwrap_or_else(|| Path::new("."));
719
720        /*
721         * Validate bootstrap path exists if specified.
722         */
723        if let Some(ref bootstrap) = file.pkgsrc.bootstrap {
724            if !bootstrap.exists() {
725                anyhow::bail!(
726                    "pkgsrc.bootstrap file {} does not exist",
727                    bootstrap.display()
728                );
729            }
730        }
731
732        /*
733         * Resolve dbdir: explicit value from options, or the platform
734         * default data directory.  Relative paths are resolved against
735         * the config file directory.
736         */
737        let raw_dbdir = file.options.as_ref().and_then(|o| o.dbdir.clone());
738        let dbdir = match raw_dbdir {
739            Some(p) if p.is_absolute() => p,
740            Some(p) => base_dir.join(p),
741            None => default_data_dir()?,
742        };
743
744        /*
745         * Default logdir to dbdir/logs if not explicitly set.
746         */
747        let logdir = file
748            .options
749            .as_ref()
750            .and_then(|o| o.logdir.clone())
751            .unwrap_or_else(|| dbdir.join("logs"));
752
753        /*
754         * Set log_level from config file, defaulting to "info".
755         */
756        let log_level = if let Some(opts) = &file.options {
757            opts.log_level.clone().unwrap_or_else(|| "info".to_string())
758        } else {
759            "info".to_string()
760        };
761
762        Ok(Config {
763            file,
764            dbdir,
765            logdir,
766            log_level,
767        })
768    }
769
770    pub fn build_threads(&self) -> usize {
771        if let Some(opts) = &self.file.options {
772            opts.build_threads.unwrap_or(1)
773        } else {
774            1
775        }
776    }
777
778    pub fn scan_threads(&self) -> usize {
779        if let Some(opts) = &self.file.options {
780            opts.scan_threads.unwrap_or(1)
781        } else {
782            1
783        }
784    }
785
786    pub fn strict_scan(&self) -> bool {
787        if let Some(opts) = &self.file.options {
788            opts.strict_scan.unwrap_or(false)
789        } else {
790            false
791        }
792    }
793
794    pub fn jobs(&self) -> Option<usize> {
795        self.file.dynamic.as_ref().and_then(|s| s.jobs)
796    }
797
798    pub fn wrkobjdir(&self) -> Option<&WrkObjDir> {
799        self.file
800            .dynamic
801            .as_ref()
802            .and_then(|s| s.wrkobjdir.as_ref())
803    }
804
805    pub fn hooks(&self) -> &[Action] {
806        match &self.file.sandboxes {
807            Some(sandboxes) => &sandboxes.hooks,
808            None => &[],
809        }
810    }
811
812    pub fn make(&self) -> &PathBuf {
813        &self.file.pkgsrc.make
814    }
815
816    pub fn pkgpaths(&self) -> &Option<Vec<PkgPath>> {
817        &self.file.pkgsrc.pkgpaths
818    }
819
820    pub fn pkgsrc(&self) -> &PathBuf {
821        &self.file.pkgsrc.basedir
822    }
823
824    pub fn sandboxes(&self) -> &Option<Sandboxes> {
825        &self.file.sandboxes
826    }
827
828    pub fn environment(&self) -> Option<&Environment> {
829        self.file
830            .sandboxes
831            .as_ref()
832            .and_then(|s| s.environment.as_ref())
833    }
834
835    pub fn publish(&self) -> Option<&Publish> {
836        self.file.publish.as_ref()
837    }
838
839    pub fn summary(&self) -> &Summary {
840        &self.file.summary
841    }
842
843    pub fn report_branch(&self) -> Option<&str> {
844        self.file
845            .publish
846            .as_ref()
847            .and_then(|p| p.report.as_ref())
848            .and_then(|r| r.branch.as_deref())
849    }
850
851    pub fn bindfs(&self) -> &str {
852        self.file
853            .sandboxes
854            .as_ref()
855            .map(|s| s.bindfs.as_str())
856            .unwrap_or("bindfs")
857    }
858
859    pub fn log_level(&self) -> &str {
860        &self.log_level
861    }
862
863    pub fn tui(&self) -> bool {
864        self.file
865            .options
866            .as_ref()
867            .and_then(|o| o.tui)
868            .unwrap_or(true)
869    }
870
871    pub fn dbdir(&self) -> &PathBuf {
872        &self.dbdir
873    }
874
875    pub fn logdir(&self) -> &PathBuf {
876        &self.logdir
877    }
878
879    pub fn save_wrkdir_patterns(&self) -> &[String] {
880        self.file.pkgsrc.save_wrkdir_patterns.as_slice()
881    }
882
883    pub fn build_user(&self) -> Option<&str> {
884        self.file.pkgsrc.build_user.as_deref()
885    }
886
887    pub fn build_user_home(&self) -> Option<&Path> {
888        self.file.pkgsrc.build_user_home.as_deref()
889    }
890
891    pub fn bootstrap(&self) -> Option<&PathBuf> {
892        self.file.pkgsrc.bootstrap.as_ref()
893    }
894
895    /// Return list of pkgsrc variable names to cache.
896    pub fn cachevars(&self) -> &[String] {
897        self.file.pkgsrc.cachevars.as_slice()
898    }
899
900    /// Validate the configuration, checking that required paths and files exist.
901    pub fn validate(&self) -> Result<(), Vec<String>> {
902        let mut errors: Vec<String> = Vec::new();
903
904        // Check pkgsrc directory exists
905        if !self.file.pkgsrc.basedir.exists() {
906            errors.push(format!(
907                "pkgsrc basedir does not exist: {}",
908                self.file.pkgsrc.basedir.display()
909            ));
910        }
911
912        // Check make binary exists (only on host if sandboxes not enabled)
913        // When sandboxes are enabled, the make binary is inside the sandbox
914        if self.file.sandboxes.is_none() && !self.file.pkgsrc.make.exists() {
915            errors.push(format!(
916                "make binary does not exist: {}",
917                self.file.pkgsrc.make.display()
918            ));
919        }
920
921        // Check sandbox basedir is writable if sandboxes enabled
922        if let Some(sandboxes) = &self.file.sandboxes {
923            // Check parent directory exists or can be created
924            if let Some(parent) = sandboxes.basedir.parent() {
925                if !parent.exists() {
926                    errors.push(format!(
927                        "Sandbox basedir parent does not exist: {}",
928                        parent.display()
929                    ));
930                }
931            }
932        }
933
934        // Check dbdir can be created
935        if let Some(parent) = self.dbdir.parent() {
936            if !parent.exists() {
937                errors.push(format!(
938                    "dbdir parent directory does not exist: {}",
939                    parent.display()
940                ));
941            }
942        }
943
944        // Thread counts must be at least 1
945        if let Some(opts) = &self.file.options {
946            if opts.build_threads == Some(0) {
947                errors.push("build_threads must be at least 1".to_string());
948            }
949            if opts.scan_threads == Some(0) {
950                errors.push("scan_threads must be at least 1".to_string());
951            }
952        }
953
954        // Dynamic resource allocation validation
955        if let Some(dyn_cfg) = &self.file.dynamic {
956            if dyn_cfg.jobs == Some(0) {
957                errors.push("dynamic.jobs must be at least 1".to_string());
958            }
959            if let Some(w) = &dyn_cfg.wrkobjdir {
960                if w.tmpfs.is_none() && w.disk.is_none() {
961                    errors.push(
962                        "dynamic.wrkobjdir requires at least one of tmpfs or disk".to_string(),
963                    );
964                }
965                if w.tmpfs.is_some() && w.disk.is_some() && w.threshold.is_none() {
966                    errors.push(
967                        "dynamic.wrkobjdir.threshold is required when both \
968                         tmpfs and disk are set"
969                            .to_string(),
970                    );
971                }
972                if !w.always_disk.is_empty() && w.disk.is_none() {
973                    errors.push(
974                        "dynamic.wrkobjdir.always_disk requires dynamic.wrkobjdir.disk to be set"
975                            .to_string(),
976                    );
977                }
978            }
979        }
980
981        if let Some(publish) = &self.file.publish {
982            if let Some(pkgs) = &publish.packages {
983                if pkgs.host.is_empty() {
984                    errors.push("publish.packages.host must not be empty".to_string());
985                }
986                if pkgs.path.is_empty() {
987                    errors.push("publish.packages.path must not be empty".to_string());
988                }
989                if let Some(tmppath) = &pkgs.tmppath {
990                    if tmppath.is_empty() {
991                        errors.push("publish.packages.tmppath must not be empty".to_string());
992                    }
993                }
994            }
995            if let Some(report) = &publish.report {
996                if report.host.is_empty() {
997                    errors.push("publish.report.host must not be empty".to_string());
998                }
999                if report.path.is_empty() {
1000                    errors.push("publish.report.path must not be empty".to_string());
1001                }
1002            }
1003        }
1004
1005        if errors.is_empty() {
1006            Ok(())
1007        } else {
1008            Err(errors)
1009        }
1010    }
1011}
1012
1013/**
1014 * Return the default configuration file path.
1015 *
1016 * If `BOB_SYSCONFDIR` was set at compile time (e.g. by pkgsrc to
1017 * `/usr/pkg/etc/bob`), uses `$BOB_SYSCONFDIR/config.lua`.  Otherwise
1018 * uses the XDG config directory (`~/.config/bob/config.lua`).
1019 */
1020pub fn default_config_path() -> Result<PathBuf> {
1021    let dir = match option_env!("BOB_SYSCONFDIR") {
1022        Some(dir) => PathBuf::from(dir),
1023        None => {
1024            let xdg = xdg::BaseDirectories::new();
1025            let config_home = xdg
1026                .config_home
1027                .context("Unable to determine XDG config directory (HOME not set?)")?;
1028            config_home.join("bob")
1029        }
1030    };
1031    Ok(dir.join("config.lua"))
1032}
1033
1034/**
1035 * Return the default data directory for databases and logs.
1036 *
1037 * If `BOB_DATADIR` was set at compile time (e.g. by pkgsrc to
1038 * `/var/db/bob`), uses that directly.  Otherwise uses the XDG data
1039 * directory (`~/.local/share/bob`).
1040 */
1041pub fn default_data_dir() -> Result<PathBuf> {
1042    match option_env!("BOB_DATADIR") {
1043        Some(dir) => Ok(PathBuf::from(dir)),
1044        None => {
1045            let xdg = xdg::BaseDirectories::new();
1046            let dir = xdg
1047                .data_home
1048                .context("Unable to determine XDG data directory (HOME not set?)")?;
1049            Ok(dir.join("bob"))
1050        }
1051    }
1052}
1053
1054/// Load and parse a Lua configuration file.
1055fn load_lua(filename: &Path) -> Result<ConfigFile, String> {
1056    let lua = Lua::new();
1057
1058    // Add config directory to package.path so require() finds relative modules
1059    if let Some(config_dir) = filename.parent() {
1060        let globals = lua.globals();
1061        let pkg: Table = globals
1062            .get("package")
1063            .map_err(|e| format!("Failed to get package table: {}", e))?;
1064        let existing: String = pkg
1065            .get("path")
1066            .map_err(|e| format!("Failed to get package.path: {}", e))?;
1067        let new_path = format!("{}/?.lua;{}", config_dir.display(), existing);
1068        pkg.set("path", new_path)
1069            .map_err(|e| format!("Failed to set package.path: {}", e))?;
1070    }
1071
1072    // Load built-in helper functions
1073    lua.load(include_str!("funcs.lua"))
1074        .exec()
1075        .map_err(|e| format!("Failed to load helper functions: {}", e))?;
1076
1077    lua.load(filename)
1078        .exec()
1079        .map_err(|e| format!("Lua execution error: {}", e))?;
1080
1081    // Get the global table (Lua script should set global variables)
1082    let globals = lua.globals();
1083
1084    reject_old_config(&globals)?;
1085
1086    // Parse each section
1087    let options =
1088        parse_options(&globals).map_err(|e| format!("Error parsing options config: {}", e))?;
1089    let pkgsrc =
1090        parse_pkgsrc(&globals).map_err(|e| format!("Error parsing pkgsrc config: {}", e))?;
1091    let sandboxes =
1092        parse_sandboxes(&globals).map_err(|e| format!("Error parsing sandboxes config: {}", e))?;
1093    let dynamic =
1094        parse_dynamic(&globals).map_err(|e| format!("Error parsing dynamic config: {}", e))?;
1095    let publish =
1096        parse_publish(&globals).map_err(|e| format!("Error parsing publish config: {}", e))?;
1097    let summary =
1098        parse_summary(&globals).map_err(|e| format!("Error parsing summary config: {}", e))?;
1099
1100    Ok(ConfigFile {
1101        options,
1102        pkgsrc,
1103        sandboxes,
1104        dynamic,
1105        publish,
1106        summary,
1107    })
1108}
1109
1110/// Build the migration error message for an unsupported config key.
1111fn old_config_error(key: &str) -> String {
1112    format!(
1113        "\n\n\
1114        '{}' is no longer a supported configuration key.\n\n\
1115        The configuration file format and the default location have changed.  Run\n\
1116        'bob init' to generate a new file and merge any changes required for your\n\
1117        setup.  See https://docs.rs/bob/latest/bob/config/ for more information.",
1118        key
1119    )
1120}
1121
1122/**
1123 * Check for config keys from older versions and produce a helpful error
1124 * directing users to regenerate their config with `bob init`.
1125 */
1126fn reject_old_config(globals: &Table) -> Result<(), String> {
1127    let old_top_level = ["scripts", "environment"];
1128    for key in &old_top_level {
1129        let val: Value = globals
1130            .get(*key)
1131            .map_err(|e| format!("Error reading config: {}", e))?;
1132        if !val.is_nil() {
1133            return Err(old_config_error(key));
1134        }
1135    }
1136
1137    let sandboxes: Value = globals
1138        .get("sandboxes")
1139        .map_err(|e| format!("Error reading config: {}", e))?;
1140    if let Some(table) = sandboxes.as_table() {
1141        // `actions` was the original action list field, before the
1142        // split into `setup`/`build`.  `build` was the per-package
1143        // action list before it was renamed to `hooks`.
1144        for key in ["actions", "build"] {
1145            let val: Value = table
1146                .get(key)
1147                .map_err(|e| format!("Error reading config: {}", e))?;
1148            if !val.is_nil() {
1149                return Err(old_config_error(&format!("sandboxes.{}", key)));
1150            }
1151        }
1152
1153        // `sandboxes.environment` previously had top-level `clear`,
1154        // `inherit`, and `set` fields.  These are now nested inside
1155        // per-context sub-tables (`build` and `dev`), each of which
1156        // has its own `clear`, `inherit`, and `vars`.
1157        let env: Value = table
1158            .get("environment")
1159            .map_err(|e| format!("Error reading config: {}", e))?;
1160        if let Some(env_table) = env.as_table() {
1161            for key in ["clear", "inherit", "set"] {
1162                let val: Value = env_table
1163                    .get(key)
1164                    .map_err(|e| format!("Error reading config: {}", e))?;
1165                if !val.is_nil() {
1166                    return Err(old_config_error(&format!("sandboxes.environment.{}", key)));
1167                }
1168            }
1169        }
1170    }
1171
1172    let pkgsrc: Value = globals
1173        .get("pkgsrc")
1174        .map_err(|e| format!("Error reading config: {}", e))?;
1175    if let Some(table) = pkgsrc.as_table() {
1176        for key in ["env", "logdir"] {
1177            let val: Value = table
1178                .get(key)
1179                .map_err(|e| format!("Error reading config: {}", e))?;
1180            if !val.is_nil() {
1181                return Err(old_config_error(&format!("pkgsrc.{}", key)));
1182            }
1183        }
1184    }
1185
1186    let publish: Value = globals
1187        .get("publish")
1188        .map_err(|e| format!("Error reading config: {}", e))?;
1189    if let Some(table) = publish.as_table() {
1190        let val: Value = table
1191            .get("rsync_args")
1192            .map_err(|e| format!("Error reading config: {}", e))?;
1193        if !val.is_nil() {
1194            return Err(old_config_error("publish.rsync_args"));
1195        }
1196    }
1197
1198    Ok(())
1199}
1200
1201/// Compression formats supported by `summary.compression`.
1202const VALID_COMPRESSION: &[&str] = &["gz", "zst"];
1203
1204fn parse_summary(globals: &Table) -> LuaResult<Summary> {
1205    let value: Value = globals.get("summary")?;
1206    if value.is_nil() {
1207        return Ok(Summary::default());
1208    }
1209
1210    let table = value
1211        .as_table()
1212        .ok_or_else(|| mlua::Error::runtime("'summary' must be a table"))?;
1213
1214    const KNOWN_KEYS: &[&str] = &["compression", "file_cksum", "include_restricted"];
1215    warn_unknown_keys(table, "summary", KNOWN_KEYS);
1216
1217    let defaults = Summary::default();
1218
1219    let include_restricted = table
1220        .get::<Option<bool>>("include_restricted")?
1221        .unwrap_or(defaults.include_restricted);
1222    let file_cksum = table
1223        .get::<Option<bool>>("file_cksum")?
1224        .unwrap_or(defaults.file_cksum);
1225    let compression = if table.contains_key("compression")? {
1226        let list = get_string_list(table, "compression", "summary")?;
1227        if list.is_empty() {
1228            return Err(mlua::Error::runtime(
1229                "summary.compression must list at least one format",
1230            ));
1231        }
1232        for c in &list {
1233            if !VALID_COMPRESSION.contains(&c.as_str()) {
1234                return Err(mlua::Error::runtime(format!(
1235                    "summary.compression value '{}' is not supported (valid: gz, zst)",
1236                    c
1237                )));
1238            }
1239        }
1240        list
1241    } else {
1242        defaults.compression
1243    };
1244
1245    Ok(Summary {
1246        include_restricted,
1247        file_cksum,
1248        compression,
1249    })
1250}
1251
1252fn parse_options(globals: &Table) -> LuaResult<Option<Options>> {
1253    let options: Value = globals.get("options")?;
1254    if options.is_nil() {
1255        return Ok(None);
1256    }
1257
1258    let table = options
1259        .as_table()
1260        .ok_or_else(|| mlua::Error::runtime("'options' must be a table"))?;
1261
1262    const KNOWN_KEYS: &[&str] = &[
1263        "build_threads",
1264        "dbdir",
1265        "log_level",
1266        "logdir",
1267        "scan_threads",
1268        "tui",
1269        "strict_scan",
1270    ];
1271    warn_unknown_keys(table, "options", KNOWN_KEYS);
1272
1273    let dbdir: Option<PathBuf> = table.get::<Option<String>>("dbdir")?.map(PathBuf::from);
1274    let logdir: Option<PathBuf> = table.get::<Option<String>>("logdir")?.map(PathBuf::from);
1275
1276    Ok(Some(Options {
1277        build_threads: table.get::<Option<usize>>("build_threads")?,
1278        dbdir,
1279        logdir,
1280        scan_threads: table.get::<Option<usize>>("scan_threads")?,
1281        strict_scan: table.get::<Option<bool>>("strict_scan")?,
1282        log_level: table.get::<Option<String>>("log_level")?,
1283        tui: table.get::<Option<bool>>("tui")?,
1284    }))
1285}
1286
1287/// Warn about unknown keys in a Lua table.
1288fn warn_unknown_keys(table: &Table, table_name: &str, known_keys: &[&str]) {
1289    for (key, _) in table.pairs::<String, Value>().flatten() {
1290        if !known_keys.contains(&key.as_str()) {
1291            eprintln!("Warning: unknown config key '{}.{}'", table_name, key);
1292        }
1293    }
1294}
1295
1296fn get_required_string(table: &Table, field: &str) -> LuaResult<String> {
1297    let value: Value = table.get(field)?;
1298    match value {
1299        Value::String(s) => Ok(s.to_str()?.to_string()),
1300        Value::Integer(n) => Ok(n.to_string()),
1301        Value::Number(n) => Ok(n.to_string()),
1302        Value::Nil => Err(mlua::Error::runtime(format!(
1303            "missing required field '{}'",
1304            field
1305        ))),
1306        _ => Err(mlua::Error::runtime(format!(
1307            "field '{}' must be a string, got {}",
1308            field,
1309            value.type_name()
1310        ))),
1311    }
1312}
1313
1314fn get_string_list(t: &Table, key: &str, q: &str) -> LuaResult<Vec<String>> {
1315    match t.get::<Value>(key)? {
1316        Value::Nil => Ok(Vec::new()),
1317        Value::Table(list) => {
1318            if list.pairs::<Value, Value>().count() != list.raw_len() {
1319                return Err(mlua::Error::runtime(format!(
1320                    "'{}.{}' must be a list, not a keyed table",
1321                    q, key
1322                )));
1323            }
1324            list.sequence_values::<Value>()
1325                .enumerate()
1326                .map(|(i, v)| match v? {
1327                    Value::String(s) => Ok(s.to_str()?.to_string()),
1328                    _ => Err(mlua::Error::runtime(format!(
1329                        "'{}.{}[{}]' must be a string",
1330                        q,
1331                        key,
1332                        i + 1
1333                    ))),
1334                })
1335                .collect()
1336        }
1337        _ => Err(mlua::Error::runtime(format!(
1338            "'{}.{}' must be a table",
1339            q, key
1340        ))),
1341    }
1342}
1343
1344fn get_string_map(t: &Table, key: &str, q: &str) -> LuaResult<HashMap<String, String>> {
1345    match t.get::<Value>(key)? {
1346        Value::Nil => Ok(HashMap::new()),
1347        Value::Table(map) => map
1348            .pairs::<String, Value>()
1349            .map(|p| {
1350                let (k, v) = p?;
1351                match v {
1352                    Value::String(s) => Ok((k, s.to_str()?.to_string())),
1353                    _ => Err(mlua::Error::runtime(format!(
1354                        "'{}.{}.{}' must be a string",
1355                        q, key, k
1356                    ))),
1357                }
1358            })
1359            .collect(),
1360        _ => Err(mlua::Error::runtime(format!(
1361            "'{}.{}' must be a table",
1362            q, key
1363        ))),
1364    }
1365}
1366
1367/**
1368 * A shell script bundled with the environment variables that should be
1369 * set when it runs.  Used for script-typed config fields like
1370 * `publish.packages.swapcmd` and the `create`/`destroy` fields of
1371 * sandbox setup actions.
1372 */
1373#[derive(Clone, Debug, Default)]
1374pub struct ScriptValue {
1375    pub run: String,
1376    pub env: Vec<(String, String)>,
1377}
1378
1379/**
1380 * Read a script-typed config field.  Accepts two forms:
1381 *
1382 * - A literal string (no env vars).
1383 * - A function returning the result of `scriptenv(run, env)`, so the
1384 *   env values can reference other config sections after the whole
1385 *   config has loaded.
1386 *
1387 * Returns Ok(None) if the field is nil or the script body is empty.
1388 */
1389pub(crate) fn get_optional_script(table: &Table, field: &str) -> LuaResult<Option<ScriptValue>> {
1390    let value: Value = table.get(field)?;
1391    let sv = match value {
1392        Value::Nil => return Ok(None),
1393        Value::String(s) => ScriptValue {
1394            run: s.to_str()?.to_string(),
1395            env: Vec::new(),
1396        },
1397        Value::Function(f) => {
1398            let result: Table = f
1399                .call(())
1400                .map_err(|e| mlua::Error::runtime(format!("'{}' function failed: {}", field, e)))?;
1401            script_value_from_table(field, &result)?
1402        }
1403        _ => {
1404            return Err(mlua::Error::runtime(format!(
1405                "field '{}' must be a string or function, got {}",
1406                field,
1407                value.type_name()
1408            )));
1409        }
1410    };
1411    if sv.run.is_empty() {
1412        Ok(None)
1413    } else {
1414        Ok(Some(sv))
1415    }
1416}
1417
1418fn script_value_from_table(field: &str, t: &Table) -> LuaResult<ScriptValue> {
1419    let run: String = t.get::<Option<String>>("run")?.ok_or_else(|| {
1420        mlua::Error::runtime(format!("'{}' table must have a 'run' string field", field))
1421    })?;
1422    let env = match t.get::<Value>("env")? {
1423        Value::Nil => Vec::new(),
1424        Value::Table(et) => {
1425            let mut pairs: Vec<(String, String)> = Vec::new();
1426            for entry in et.pairs::<String, Value>() {
1427                let (k, v) = entry?;
1428                if !is_valid_env_key(&k) {
1429                    return Err(mlua::Error::runtime(format!(
1430                        "'{}.env' key '{}' is not a valid shell identifier \
1431                         (must match [A-Za-z_][A-Za-z0-9_]*)",
1432                        field, k
1433                    )));
1434                }
1435                let v = match v {
1436                    Value::String(s) => s.to_str()?.to_string(),
1437                    Value::Integer(n) => n.to_string(),
1438                    Value::Number(n) => n.to_string(),
1439                    Value::Boolean(b) => b.to_string(),
1440                    _ => {
1441                        return Err(mlua::Error::runtime(format!(
1442                            "'{}.env.{}' must be a string, number, or boolean, got {}",
1443                            field,
1444                            k,
1445                            v.type_name()
1446                        )));
1447                    }
1448                };
1449                pairs.push((k, v));
1450            }
1451            pairs.sort_by(|a, b| a.0.cmp(&b.0));
1452            pairs
1453        }
1454        _ => {
1455            return Err(mlua::Error::runtime(format!(
1456                "'{}.env' must be a table",
1457                field
1458            )));
1459        }
1460    };
1461    Ok(ScriptValue { run, env })
1462}
1463
1464/**
1465 * A valid shell identifier matches [A-Za-z_][A-Za-z0-9_]*.  Used to
1466 * validate env var names so they can be safely interpolated into shell
1467 * preludes and referenced as ${name} from script bodies.
1468 */
1469fn is_valid_env_key(s: &str) -> bool {
1470    let mut chars = s.chars();
1471    match chars.next() {
1472        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
1473        _ => return false,
1474    }
1475    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1476}
1477
1478/// Parse a human-readable size string into bytes.
1479///
1480/// Accepts integer suffixes K, M, G, T (case-insensitive) with optional
1481/// fractional parts (e.g. "1.5G"), or bare byte counts.
1482fn parse_size(s: &str) -> Result<u64, String> {
1483    let s = s.trim();
1484    if s.is_empty() {
1485        return Err("empty size string".to_string());
1486    }
1487
1488    let (num_str, multiplier) = match s.as_bytes().last() {
1489        Some(b'K' | b'k') => (&s[..s.len() - 1], 1024u64),
1490        Some(b'M' | b'm') => (&s[..s.len() - 1], 1024u64 * 1024),
1491        Some(b'G' | b'g') => (&s[..s.len() - 1], 1024u64 * 1024 * 1024),
1492        Some(b'T' | b't') => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
1493        _ => (s, 1u64),
1494    };
1495
1496    if multiplier > 1 {
1497        let n: f64 = num_str
1498            .parse()
1499            .map_err(|_| format!("invalid size: '{}'", s))?;
1500        if n < 0.0 {
1501            return Err(format!("negative size: '{}'", s));
1502        }
1503        Ok((n * multiplier as f64) as u64)
1504    } else {
1505        s.parse::<u64>()
1506            .map_err(|_| format!("invalid size: '{}'", s))
1507    }
1508}
1509
1510/**
1511 * Look up a user's home directory from the password database.
1512 */
1513fn get_home_dir(username: &str) -> Result<PathBuf, String> {
1514    let cname = CString::new(username).map_err(|_| format!("invalid username: '{}'", username))?;
1515    // SAFETY: getpwnam is called with a valid C string.
1516    let pw = unsafe { libc::getpwnam(cname.as_ptr()) };
1517    if pw.is_null() {
1518        return Err(format!(
1519            "user '{}' not found in password database",
1520            username
1521        ));
1522    }
1523    // SAFETY: pw is non-null and pw_dir is a valid C string.
1524    let home = unsafe { CStr::from_ptr((*pw).pw_dir) };
1525    let path = home
1526        .to_str()
1527        .map_err(|_| format!("non-UTF-8 home directory for user '{}'", username))?;
1528    Ok(PathBuf::from(path))
1529}
1530
1531fn parse_dynamic(globals: &Table) -> LuaResult<Option<DynamicConfig>> {
1532    let value: Value = globals.get("dynamic")?;
1533    if value.is_nil() {
1534        return Ok(None);
1535    }
1536
1537    let table = value
1538        .as_table()
1539        .ok_or_else(|| mlua::Error::runtime("'dynamic' must be a table"))?;
1540
1541    const KNOWN_KEYS: &[&str] = &["jobs", "wrkobjdir"];
1542    warn_unknown_keys(table, "dynamic", KNOWN_KEYS);
1543
1544    let jobs: Option<usize> = table.get::<Option<usize>>("jobs")?;
1545
1546    let wrkobjdir = match table.get::<Value>("wrkobjdir")? {
1547        Value::Nil => None,
1548        Value::Table(t) => {
1549            const WRK_KEYS: &[&str] = &[
1550                "tmpfs",
1551                "disk",
1552                "threshold",
1553                "failed_threshold",
1554                "always_disk",
1555            ];
1556            warn_unknown_keys(&t, "dynamic.wrkobjdir", WRK_KEYS);
1557
1558            let tmpfs: Option<PathBuf> = t.get::<Option<String>>("tmpfs")?.map(PathBuf::from);
1559            let disk: Option<PathBuf> = t.get::<Option<String>>("disk")?.map(PathBuf::from);
1560            let threshold: Option<u64> = t
1561                .get::<Option<String>>("threshold")?
1562                .map(|s| {
1563                    parse_size(&s).map_err(|e| {
1564                        mlua::Error::runtime(format!("dynamic.wrkobjdir.threshold: {}", e))
1565                    })
1566                })
1567                .transpose()?;
1568            let failed_threshold: Option<u64> = t
1569                .get::<Option<String>>("failed_threshold")?
1570                .map(|s| {
1571                    parse_size(&s).map_err(|e| {
1572                        mlua::Error::runtime(format!("dynamic.wrkobjdir.failed_threshold: {}", e))
1573                    })
1574                })
1575                .transpose()?;
1576            let always_disk: Vec<String> = t
1577                .get::<Option<Vec<String>>>("always_disk")?
1578                .unwrap_or_default();
1579
1580            Some(WrkObjDir {
1581                tmpfs,
1582                disk,
1583                threshold,
1584                failed_threshold,
1585                always_disk,
1586            })
1587        }
1588        _ => return Err(mlua::Error::runtime("dynamic.wrkobjdir must be a table")),
1589    };
1590
1591    Ok(Some(DynamicConfig { jobs, wrkobjdir }))
1592}
1593
1594fn parse_pkgsrc(globals: &Table) -> LuaResult<Pkgsrc> {
1595    let pkgsrc: Table = globals.get("pkgsrc")?;
1596
1597    const KNOWN_KEYS: &[&str] = &[
1598        "basedir",
1599        "bootstrap",
1600        "build_user",
1601        "build_user_home",
1602        "cachevars",
1603        "make",
1604        "pkgpaths",
1605        "save_wrkdir_patterns",
1606    ];
1607    warn_unknown_keys(&pkgsrc, "pkgsrc", KNOWN_KEYS);
1608
1609    let basedir = get_required_string(&pkgsrc, "basedir")?;
1610    let bootstrap: Option<PathBuf> = pkgsrc
1611        .get::<Option<String>>("bootstrap")?
1612        .map(PathBuf::from);
1613    let build_user: Option<String> = pkgsrc.get::<Option<String>>("build_user")?;
1614    let build_user_home = if let Some(ref user) = build_user {
1615        if let Some(explicit) = pkgsrc.get::<Option<String>>("build_user_home")? {
1616            Some(PathBuf::from(explicit))
1617        } else {
1618            let home = get_home_dir(user)
1619                .map_err(|e| mlua::Error::runtime(format!("pkgsrc.build_user: {}", e)))?;
1620            pkgsrc.set("build_user_home", home.display().to_string())?;
1621            Some(home)
1622        }
1623    } else {
1624        None
1625    };
1626    let make = get_required_string(&pkgsrc, "make")?;
1627
1628    let pkgpaths: Option<Vec<PkgPath>> = match pkgsrc.get::<Value>("pkgpaths")? {
1629        Value::Nil => None,
1630        Value::Table(t) => {
1631            let mut paths = Vec::new();
1632            for (i, val) in t.sequence_values::<Value>().enumerate() {
1633                let val = val.map_err(|e| {
1634                    mlua::Error::runtime(format!("pkgsrc.pkgpaths[{}]: {}", i + 1, e))
1635                })?;
1636                let Value::String(s) = val else {
1637                    return Err(mlua::Error::runtime(format!(
1638                        "pkgsrc.pkgpaths[{}]: expected string",
1639                        i + 1
1640                    )));
1641                };
1642                let s = s.to_str().map_err(|e| {
1643                    mlua::Error::runtime(format!("pkgsrc.pkgpaths[{}]: {}", i + 1, e))
1644                })?;
1645                match PkgPath::new(&s) {
1646                    Ok(p) => paths.push(p),
1647                    Err(e) => {
1648                        return Err(mlua::Error::runtime(format!(
1649                            "pkgsrc.pkgpaths[{}]: invalid pkgpath '{}': {}",
1650                            i + 1,
1651                            s,
1652                            e
1653                        )));
1654                    }
1655                }
1656            }
1657            if paths.is_empty() { None } else { Some(paths) }
1658        }
1659        _ => None,
1660    };
1661
1662    let save_wrkdir_patterns = get_string_list(&pkgsrc, "save_wrkdir_patterns", "pkgsrc")?;
1663    let cachevars = get_string_list(&pkgsrc, "cachevars", "pkgsrc")?;
1664
1665    Ok(Pkgsrc {
1666        basedir: PathBuf::from(basedir),
1667        bootstrap,
1668        build_user,
1669        build_user_home,
1670        cachevars,
1671        make: PathBuf::from(make),
1672        pkgpaths,
1673        save_wrkdir_patterns,
1674    })
1675}
1676
1677fn parse_sandboxes(globals: &Table) -> LuaResult<Option<Sandboxes>> {
1678    let sandboxes: Value = globals.get("sandboxes")?;
1679    if sandboxes.is_nil() {
1680        return Ok(None);
1681    }
1682
1683    let table = sandboxes
1684        .as_table()
1685        .ok_or_else(|| mlua::Error::runtime("'sandboxes' must be a table"))?;
1686
1687    const KNOWN_KEYS: &[&str] = &["basedir", "bindfs", "environment", "hooks", "setup"];
1688    warn_unknown_keys(table, "sandboxes", KNOWN_KEYS);
1689
1690    let basedir: String = table.get("basedir")?;
1691    let bindfs: String = table
1692        .get::<Option<String>>("bindfs")?
1693        .unwrap_or_else(|| String::from("bindfs"));
1694
1695    let setup = parse_action_list(table, globals, "setup", "sandboxes.setup")?;
1696    let hooks = parse_action_list(table, globals, "hooks", "sandboxes.hooks")?;
1697    let environment = parse_environment(table)?;
1698
1699    Ok(Some(Sandboxes {
1700        basedir: PathBuf::from(basedir),
1701        setup,
1702        hooks,
1703        environment,
1704        bindfs,
1705    }))
1706}
1707
1708fn parse_action_list(
1709    table: &Table,
1710    globals: &Table,
1711    key: &str,
1712    label: &str,
1713) -> LuaResult<Vec<Action>> {
1714    let value: Value = table.get(key)?;
1715    if value.is_nil() {
1716        return Ok(Vec::new());
1717    }
1718    let actions_table = value
1719        .as_table()
1720        .ok_or_else(|| mlua::Error::runtime(format!("'{label}' must be a table")))?;
1721    parse_actions(actions_table, globals)
1722}
1723
1724fn parse_actions(table: &Table, globals: &Table) -> LuaResult<Vec<Action>> {
1725    let mut actions = Vec::new();
1726    for v in table.sequence_values::<Table>() {
1727        let action_table = v?;
1728
1729        // The `ifset` and `ifexists` action fields were replaced by
1730        // `only = { set = ... }` and `only = { exists = ... }`
1731        // respectively.  Reject the old form so users don't silently
1732        // lose their conditionals.
1733        for key in ["ifset", "ifexists"] {
1734            let val: Value = action_table.get(key)?;
1735            if !val.is_nil() {
1736                return Err(mlua::Error::runtime(old_config_error(key)));
1737            }
1738        }
1739
1740        match parse_action_only(&action_table, globals)? {
1741            Some(only) => {
1742                let mut action = Action::from_lua(&action_table)?;
1743                action.set_only(only);
1744                actions.push(action);
1745            }
1746            None => {
1747                // The parse-time `only.set` check failed: drop the action.
1748            }
1749        }
1750    }
1751    Ok(actions)
1752}
1753
1754/// Parse the `only = { ... }` predicate table for an action.
1755///
1756/// Returns `Some(only)` if the action should be kept (with the runtime
1757/// predicates populated), or `None` if a parse-time predicate (`set`)
1758/// failed and the action should be dropped.  Actions without an `only`
1759/// table return `Some(Only::default())`.
1760fn parse_action_only(
1761    action_table: &Table,
1762    globals: &Table,
1763) -> LuaResult<Option<crate::action::Only>> {
1764    use crate::action::{ActionContext, Only};
1765
1766    let only_value: Value = action_table.get("only")?;
1767    let only_table = match only_value {
1768        Value::Nil => return Ok(Some(Only::default())),
1769        Value::Table(t) => t,
1770        _ => {
1771            return Err(mlua::Error::runtime("'only' must be a table of predicates"));
1772        }
1773    };
1774
1775    const ONLY_KEYS: &[&str] = &["environment", "set", "exists"];
1776    warn_unknown_keys(&only_table, "only", ONLY_KEYS);
1777
1778    let mut only = Only::default();
1779
1780    if let Some(env_str) = only_table.get::<Option<String>>("environment")? {
1781        let env = match env_str.as_str() {
1782            "build" => ActionContext::Build,
1783            "dev" => ActionContext::Dev,
1784            other => {
1785                return Err(mlua::Error::runtime(format!(
1786                    "'only.environment' must be 'build' or 'dev', got '{}'",
1787                    other
1788                )));
1789            }
1790        };
1791        only.environment = Some(env);
1792    }
1793
1794    // `set` is checked at parse time against the Lua globals; if the
1795    // referenced var is unset, the action is dropped entirely.
1796    if let Some(varpath) = only_table.get::<Option<String>>("set")? {
1797        if resolve_lua_var(globals, &varpath).is_none() {
1798            return Ok(None);
1799        }
1800    }
1801
1802    if let Some(path_str) = only_table.get::<Option<String>>("exists")? {
1803        only.exists = Some(PathBuf::from(path_str));
1804    }
1805
1806    Ok(Some(only))
1807}
1808
1809/**
1810 * Resolve a dotted variable path (e.g. "pkgsrc.build_user") by
1811 * walking the Lua globals table.
1812 */
1813fn resolve_lua_var(globals: &Table, path: &str) -> Option<String> {
1814    let mut parts = path.split('.');
1815    let first = parts.next()?;
1816    let mut current: Value = globals.get(first).ok()?;
1817    for key in parts {
1818        match current {
1819            Value::Table(t) => {
1820                current = t.get(key).ok()?;
1821            }
1822            _ => return None,
1823        }
1824    }
1825    match current {
1826        Value::String(s) => Some(s.to_str().ok()?.to_string()),
1827        Value::Integer(n) => Some(n.to_string()),
1828        Value::Number(n) => Some(n.to_string()),
1829        _ => None,
1830    }
1831}
1832
1833fn parse_publish(globals: &Table) -> LuaResult<Option<Publish>> {
1834    let value: Value = globals.get("publish")?;
1835    if value.is_nil() {
1836        return Ok(None);
1837    }
1838
1839    let table = value
1840        .as_table()
1841        .ok_or_else(|| mlua::Error::runtime("'publish' must be a table"))?;
1842
1843    const KNOWN_KEYS: &[&str] = &["packages", "report", "rsync"];
1844    warn_unknown_keys(table, "publish", KNOWN_KEYS);
1845
1846    let rsync: PathBuf = table
1847        .get::<Option<String>>("rsync")?
1848        .map(PathBuf::from)
1849        .unwrap_or_else(|| PathBuf::from("rsync"));
1850
1851    let packages = match table.get::<Value>("packages")? {
1852        Value::Nil => None,
1853        Value::Table(t) => {
1854            const PKG_KEYS: &[&str] = &[
1855                "host",
1856                "minimum",
1857                "path",
1858                "required",
1859                "rsync_args",
1860                "swapcmd",
1861                "tmppath",
1862                "user",
1863            ];
1864            warn_unknown_keys(&t, "publish.packages", PKG_KEYS);
1865
1866            let host: String = t
1867                .get::<Option<String>>("host")?
1868                .ok_or_else(|| mlua::Error::runtime("publish.packages.host is required"))?;
1869            let user: Option<String> = t.get::<Option<String>>("user")?;
1870            let path: String = t
1871                .get::<Option<String>>("path")?
1872                .ok_or_else(|| mlua::Error::runtime("publish.packages.path is required"))?;
1873            let tmppath: Option<String> = t
1874                .get::<Option<String>>("tmppath")?
1875                .filter(|s| !s.is_empty());
1876            let swapcmd: Option<ScriptValue> = get_optional_script(&t, "swapcmd")?;
1877            let minimum: Option<usize> = t.get::<Option<usize>>("minimum")?;
1878            let required = get_string_list(&t, "required", "publish.packages")?;
1879            let rsync_args: String = t
1880                .get::<Option<String>>("rsync_args")?
1881                .unwrap_or_else(|| "-av --delete-excluded -e ssh".to_string());
1882
1883            if swapcmd.is_some() && tmppath.is_none() {
1884                return Err(mlua::Error::runtime(
1885                    "publish.packages.swapcmd requires tmppath to be set",
1886                ));
1887            }
1888
1889            Some(PublishPackages {
1890                host,
1891                user,
1892                path,
1893                tmppath,
1894                swapcmd,
1895                minimum,
1896                required,
1897                rsync_args,
1898            })
1899        }
1900        _ => return Err(mlua::Error::runtime("publish.packages must be a table")),
1901    };
1902
1903    let report = match table.get::<Value>("report")? {
1904        Value::Nil => None,
1905        Value::Table(t) => {
1906            const RPT_KEYS: &[&str] = &[
1907                "branch",
1908                "from",
1909                "host",
1910                "path",
1911                "rsync_args",
1912                "to",
1913                "url",
1914                "user",
1915            ];
1916            warn_unknown_keys(&t, "publish.report", RPT_KEYS);
1917
1918            let host: String = t
1919                .get::<Option<String>>("host")?
1920                .ok_or_else(|| mlua::Error::runtime("publish.report.host is required"))?;
1921            let user: Option<String> = t.get::<Option<String>>("user")?;
1922            let path: String = t
1923                .get::<Option<String>>("path")?
1924                .ok_or_else(|| mlua::Error::runtime("publish.report.path is required"))?;
1925            let url: Option<String> = t.get::<Option<String>>("url")?;
1926            let rsync_args: String = t
1927                .get::<Option<String>>("rsync_args")?
1928                .unwrap_or_else(|| "-avz --delete-excluded -e ssh".to_string());
1929            let branch: Option<String> =
1930                t.get::<Option<String>>("branch")?.filter(|s| !s.is_empty());
1931            let from: Option<String> = t.get::<Option<String>>("from")?;
1932            let to: Vec<String> = match t.get::<Value>("to")? {
1933                Value::Nil => Vec::new(),
1934                Value::String(s) => vec![s.to_string_lossy().to_string()],
1935                Value::Table(r) => r
1936                    .sequence_values::<String>()
1937                    .collect::<LuaResult<Vec<_>>>()?,
1938                _ => {
1939                    return Err(mlua::Error::runtime(
1940                        "publish.report.to must be a string or table",
1941                    ));
1942                }
1943            };
1944
1945            Some(PublishReport {
1946                host,
1947                user,
1948                path,
1949                url,
1950                rsync_args,
1951                branch,
1952                from,
1953                to,
1954            })
1955        }
1956        _ => return Err(mlua::Error::runtime("publish.report must be a table")),
1957    };
1958
1959    Ok(Some(Publish {
1960        rsync,
1961        packages,
1962        report,
1963    }))
1964}
1965
1966fn parse_environment(globals: &Table) -> LuaResult<Option<Environment>> {
1967    let environment: Value = globals.get("environment")?;
1968    if environment.is_nil() {
1969        return Ok(None);
1970    }
1971
1972    let table = environment
1973        .as_table()
1974        .ok_or_else(|| mlua::Error::runtime("'environment' must be a table"))?;
1975
1976    const KNOWN_KEYS: &[&str] = &["build", "dev"];
1977    warn_unknown_keys(table, "environment", KNOWN_KEYS);
1978
1979    let build = parse_env_context(table, "build")?;
1980    let dev = parse_env_context(table, "dev")?;
1981
1982    Ok(Some(Environment { build, dev }))
1983}
1984
1985fn parse_env_context(parent: &Table, name: &str) -> LuaResult<Option<EnvContext>> {
1986    let value: Value = parent.get(name)?;
1987    let table = match value {
1988        Value::Nil => return Ok(None),
1989        Value::Table(t) => t,
1990        _ => {
1991            return Err(mlua::Error::runtime(format!(
1992                "'environment.{}' must be a table",
1993                name
1994            )));
1995        }
1996    };
1997
1998    let qualified = format!("environment.{}", name);
1999    let known_keys: &[&str] = match name {
2000        "dev" => &["clear", "inherit", "vars", "shell"],
2001        _ => &["clear", "inherit", "vars"],
2002    };
2003    warn_unknown_keys(&table, &qualified, known_keys);
2004
2005    let clear: bool = table.get::<Option<bool>>("clear")?.unwrap_or(true);
2006
2007    let inherit = get_string_list(&table, "inherit", &qualified)?;
2008    let vars = get_string_map(&table, "vars", &qualified)?;
2009
2010    let shell: Option<PathBuf> = if name == "dev" {
2011        table.get::<Option<String>>("shell")?.map(PathBuf::from)
2012    } else {
2013        None
2014    };
2015
2016    Ok(Some(EnvContext {
2017        clear,
2018        inherit,
2019        vars,
2020        shell,
2021    }))
2022}
2023
2024#[cfg(test)]
2025mod tests {
2026    use super::*;
2027
2028    fn load_config(lua_src: &str) -> Result<Config, String> {
2029        let dir = tempfile::tempdir().map_err(|e| e.to_string())?;
2030        let path = dir.path().join("config.lua");
2031        std::fs::write(&path, lua_src).map_err(|e| e.to_string())?;
2032        Config::load(Some(&path)).map_err(|e| format!("{e:#}"))
2033    }
2034
2035    const MINIMAL: &str = r#"
2036        pkgsrc = {
2037            basedir = "/usr/pkgsrc",
2038            make = "/usr/bin/make",
2039        }
2040    "#;
2041
2042    fn with_options(options: &str) -> String {
2043        format!("{MINIMAL}\noptions = {{ {options} }}")
2044    }
2045
2046    fn with_dynamic(dynamic: &str) -> String {
2047        format!("{MINIMAL}\ndynamic = {{ {dynamic} }}")
2048    }
2049
2050    #[test]
2051    fn options_valid_types() {
2052        let cfg = load_config(&with_options("build_threads = 4, scan_threads = 2"));
2053        assert!(cfg.is_ok());
2054        let cfg = cfg.ok();
2055        assert_eq!(cfg.as_ref().map(|c| c.build_threads()), Some(4));
2056        assert_eq!(cfg.as_ref().map(|c| c.scan_threads()), Some(2));
2057    }
2058
2059    #[test]
2060    fn options_wrong_type_errors() {
2061        let cfg = load_config(&with_options("build_threads = \"eight\""));
2062        assert!(cfg.is_err(), "expected error, got: {:?}", cfg);
2063    }
2064
2065    #[test]
2066    fn options_missing_is_default() {
2067        let cfg = load_config(MINIMAL);
2068        assert!(cfg.is_ok());
2069        let cfg = cfg.ok();
2070        assert_eq!(cfg.as_ref().map(|c| c.build_threads()), Some(1));
2071    }
2072
2073    #[test]
2074    fn dynamic_jobs_wrong_type_errors() {
2075        let cfg = load_config(&with_dynamic("jobs = \"lots\""));
2076        assert!(cfg.is_err(), "expected error, got: {:?}", cfg);
2077    }
2078
2079    #[test]
2080    fn pkgpaths_valid() {
2081        let lua = format!("{MINIMAL}\npkgsrc.pkgpaths = {{ \"devel/cmake\", \"lang/rust\" }}");
2082        let cfg = load_config(&lua);
2083        assert!(cfg.is_ok(), "expected ok, got: {:?}", cfg);
2084    }
2085
2086    #[test]
2087    fn pkgpaths_invalid_errors() {
2088        let lua = format!("{MINIMAL}\npkgsrc.pkgpaths = {{ \"mail\" }}");
2089        let cfg = load_config(&lua);
2090        assert!(cfg.is_err(), "expected error, got: {:?}", cfg);
2091    }
2092
2093    #[test]
2094    fn pkgpaths_wrong_type_errors() {
2095        let lua = format!("{MINIMAL}\npkgsrc.pkgpaths = {{ 42 }}");
2096        let cfg = load_config(&lua);
2097        assert!(cfg.is_err(), "expected error, got: {:?}", cfg);
2098    }
2099
2100    #[test]
2101    fn cachevars_valid() {
2102        let lua = format!("{MINIMAL}\npkgsrc.cachevars = {{ \"NATIVE_OPSYS\", \"PKGSRC\" }}");
2103        let cfg = load_config(&lua);
2104        assert!(cfg.is_ok(), "expected ok, got: {:?}", cfg);
2105        assert_eq!(
2106            cfg.unwrap().cachevars(),
2107            &["NATIVE_OPSYS".to_string(), "PKGSRC".to_string()]
2108        );
2109    }
2110
2111    #[test]
2112    fn cachevars_keyed_table_errors() {
2113        let lua = format!("{MINIMAL}\npkgsrc.cachevars = {{ NATIVE_OPSYS = true }}");
2114        let cfg = load_config(&lua);
2115        let err = cfg.expect_err("expected error");
2116        assert!(err.contains("must be a list"), "unexpected error: {}", err);
2117    }
2118
2119    #[test]
2120    fn cachevars_non_string_element_errors() {
2121        let lua = format!("{MINIMAL}\npkgsrc.cachevars = {{ \"OK\", 42 }}");
2122        let cfg = load_config(&lua);
2123        let err = cfg.expect_err("expected error");
2124        assert!(
2125            err.contains("[2]") && err.contains("must be a string"),
2126            "unexpected error: {}",
2127            err
2128        );
2129    }
2130
2131    #[test]
2132    fn cachevars_non_table_errors() {
2133        let lua = format!("{MINIMAL}\npkgsrc.cachevars = \"oops\"");
2134        let cfg = load_config(&lua);
2135        assert!(cfg.is_err(), "expected error, got: {:?}", cfg);
2136    }
2137
2138    #[test]
2139    fn environment_inherit_keyed_table_errors() {
2140        let lua = format!(
2141            "{MINIMAL}\nsandboxes = {{ basedir = \"/tmp/sb\", \
2142             environment = {{ build = {{ inherit = {{ TERM = true }} }} }} }}"
2143        );
2144        let cfg = load_config(&lua);
2145        let err = cfg.expect_err("expected error");
2146        assert!(err.contains("must be a list"), "unexpected error: {}", err);
2147    }
2148
2149    #[test]
2150    fn environment_vars_non_string_value_errors() {
2151        let lua = format!(
2152            "{MINIMAL}\nsandboxes = {{ basedir = \"/tmp/sb\", \
2153             environment = {{ build = {{ vars = {{ PATH = 42 }} }} }} }}"
2154        );
2155        let cfg = load_config(&lua);
2156        let err = cfg.expect_err("expected error");
2157        assert!(
2158            err.contains("vars.PATH") && err.contains("must be a string"),
2159            "unexpected error: {}",
2160            err
2161        );
2162    }
2163}