1use 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#[derive(Clone, Debug)]
150pub struct PkgsrcEnv {
151 pub packages: PathBuf,
153 pub pkgtools: PathBuf,
155 pub prefix: PathBuf,
157 pub pkg_dbdir: PathBuf,
159 pub pkg_refcount_dbdir: PathBuf,
161 pub metadata: HashMap<String, String>,
163 pub cachevars: HashMap<String, String>,
165}
166
167impl PkgsrcEnv {
168 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 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#[derive(Clone, Debug, Default)]
304pub struct Config {
305 file: ConfigFile,
306 dbdir: PathBuf,
307 logdir: PathBuf,
308 log_level: String,
309}
310
311#[derive(Clone, Debug, Default)]
313pub struct ConfigFile {
314 pub options: Option<Options>,
316 pub pkgsrc: Pkgsrc,
318 pub sandboxes: Option<Sandboxes>,
320 pub dynamic: Option<DynamicConfig>,
322 pub publish: Option<Publish>,
324 pub summary: Summary,
326}
327
328#[derive(Clone, Debug, Default)]
335pub struct Options {
336 pub build_threads: Option<usize>,
338 pub dbdir: Option<PathBuf>,
340 pub logdir: Option<PathBuf>,
342 pub scan_threads: Option<usize>,
344 pub strict_scan: Option<bool>,
346 pub log_level: Option<String>,
348 pub tui: Option<bool>,
350}
351
352#[derive(Clone, Debug)]
369pub struct Summary {
370 pub include_restricted: bool,
372 pub file_cksum: bool,
374 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#[derive(Clone, Debug)]
399pub struct DynamicConfig {
400 pub jobs: Option<usize>,
402 pub wrkobjdir: Option<WrkObjDir>,
404}
405
406#[derive(Clone, Debug)]
413pub struct WrkObjDir {
414 pub tmpfs: Option<PathBuf>,
416 pub disk: Option<PathBuf>,
418 pub threshold: Option<u64>,
420 pub failed_threshold: Option<u64>,
427 pub always_disk: Vec<String>,
434}
435
436impl WrkObjDir {
437 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#[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#[derive(Clone, Debug)]
476pub struct Publish {
477 pub rsync: PathBuf,
479 pub packages: Option<PublishPackages>,
481 pub report: Option<PublishReport>,
483}
484
485#[derive(Clone, Debug)]
496pub struct PublishPackages {
497 pub host: String,
499 pub user: Option<String>,
501 pub path: String,
503 pub tmppath: Option<String>,
507 pub swapcmd: Option<ScriptValue>,
512 pub minimum: Option<usize>,
514 pub required: Vec<String>,
516 pub rsync_args: String,
520}
521
522#[derive(Clone, Debug)]
524pub struct PublishReport {
525 pub host: String,
527 pub user: Option<String>,
529 pub path: String,
531 pub url: Option<String>,
533 pub rsync_args: String,
537 pub branch: Option<String>,
539 pub from: Option<String>,
541 pub to: Vec<String>,
543}
544
545#[derive(Clone, Debug, Default)]
559pub struct Pkgsrc {
560 pub basedir: PathBuf,
562 pub bootstrap: Option<PathBuf>,
564 pub build_user: Option<String>,
566 pub build_user_home: Option<PathBuf>,
568 pub make: PathBuf,
570 pub pkgpaths: Option<Vec<PkgPath>>,
572 pub save_wrkdir_patterns: Vec<String>,
574 pub cachevars: Vec<String>,
576}
577
578#[derive(Clone, Debug, Default)]
588pub struct Environment {
589 pub build: Option<EnvContext>,
592 pub dev: Option<EnvContext>,
596}
597
598#[derive(Clone, Debug)]
603pub struct EnvContext {
604 pub clear: bool,
608 pub inherit: Vec<String>,
611 pub vars: HashMap<String, String>,
617 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#[derive(Clone, Debug, Default)]
652pub struct Sandboxes {
653 pub basedir: PathBuf,
658 pub setup: Vec<Action>,
660 pub hooks: Vec<Action>,
667 pub environment: Option<Environment>,
669 pub bindfs: String,
671}
672
673impl Config {
674 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 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 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 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 let logdir = file
748 .options
749 .as_ref()
750 .and_then(|o| o.logdir.clone())
751 .unwrap_or_else(|| dbdir.join("logs"));
752
753 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 pub fn cachevars(&self) -> &[String] {
897 self.file.pkgsrc.cachevars.as_slice()
898 }
899
900 pub fn validate(&self) -> Result<(), Vec<String>> {
902 let mut errors: Vec<String> = Vec::new();
903
904 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 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 if let Some(sandboxes) = &self.file.sandboxes {
923 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 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 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 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
1013pub 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
1034pub 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
1054fn load_lua(filename: &Path) -> Result<ConfigFile, String> {
1056 let lua = Lua::new();
1057
1058 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 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 let globals = lua.globals();
1083
1084 reject_old_config(&globals)?;
1085
1086 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
1110fn 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
1122fn 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 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 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
1201const 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
1287fn 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#[derive(Clone, Debug, Default)]
1374pub struct ScriptValue {
1375 pub run: String,
1376 pub env: Vec<(String, String)>,
1377}
1378
1379pub(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
1464fn 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
1478fn 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
1510fn get_home_dir(username: &str) -> Result<PathBuf, String> {
1514 let cname = CString::new(username).map_err(|_| format!("invalid username: '{}'", username))?;
1515 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 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 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 }
1749 }
1750 }
1751 Ok(actions)
1752}
1753
1754fn 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 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
1809fn 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}