Skip to main content

lux_lib/config/
mod.rs

1use build::BuildConfig;
2use directories::ProjectDirs;
3use external_deps::ExternalDependencySearchConfig;
4use itertools::Itertools;
5
6use miette::Diagnostic;
7use serde::{Deserialize, Serialize, Serializer};
8use std::ffi::OsStr;
9use std::path::Path;
10use std::{collections::HashMap, env, path::PathBuf, sync::Arc, time::Duration};
11use thiserror::Error;
12use tokio::process::Command;
13use url::Url;
14
15use crate::config::access_tokens::AccessToken;
16use crate::fs;
17use crate::lua_version::LuaVersion;
18use crate::package::RemotePackageTypeFilterSpec;
19use crate::project::TomlDeError;
20use crate::tree::{CustomRockLayout, Tree, TreeError};
21use crate::variables::GetVariableError;
22use crate::{build::utils, variables::HasVariables};
23
24pub mod access_tokens;
25pub mod build;
26pub mod external_deps;
27
28const DEV_PATH: &str = "dev/";
29const DEFAULT_USER_AGENT: &str = concat!("lux-lib/", env!("CARGO_PKG_VERSION"));
30
31#[derive(Error, Debug, Diagnostic)]
32#[error("could not find a valid home directory")]
33#[diagnostic(
34    code(lux_lib::no_home_directory),
35    help("this usually means you're running Lux in a managed environment like LDAP or a live session.")
36)]
37pub struct NoValidHomeDirectory;
38
39/// The resolved configuration for a Lux session.
40/// Can be constructed via [`ConfigBuilder`], which supports layering multiple
41/// configuration sources (config file, CLI flags, environment variables).
42#[derive(Debug, Clone)]
43pub struct Config {
44    enable_development_packages: bool,
45    server: Url,
46    extra_servers: Vec<Url>,
47    manifest_cache_url: Option<Url>,
48    extra_wally_registries: Vec<Url>,
49    namespace: Option<String>,
50    lua_dir: Option<PathBuf>,
51    lua_version: Option<LuaVersion>,
52    user_tree: PathBuf,
53    workspace_tree: Option<PathBuf>,
54    verbose: bool,
55    /// Don't display progress bars
56    no_progress: bool,
57    /// Skip prompts (choosing the default choice)
58    no_prompt: bool,
59    timeout: Duration,
60    max_jobs: usize,
61    variables: HashMap<String, String>,
62    access_tokens: HashMap<String, AccessToken>,
63    external_deps: ExternalDependencySearchConfig,
64
65    build: BuildConfig,
66    entrypoint_layout: Option<Arc<dyn CustomRockLayout>>,
67
68    cache_dir: PathBuf,
69    data_dir: PathBuf,
70    vendor_dir: Option<PathBuf>,
71
72    user_agent: String,
73
74    generate_luarc: bool,
75    luarc_file_name: String,
76    wrap_bin_scripts: bool,
77    package_types: RemotePackageTypeFilterSpec,
78    no_tfa: bool,
79}
80
81impl Config {
82    /// Lux application directories
83    pub(crate) fn project_dirs() -> Result<ProjectDirs, NoValidHomeDirectory> {
84        directories::ProjectDirs::from("org", "lumenlabs", "lux")
85            .filter(|_| Self::home_dir_exists())
86            .ok_or(NoValidHomeDirectory)
87    }
88
89    fn home_dir_exists() -> bool {
90        directories::BaseDirs::new().is_some_and(|base| base.home_dir().is_dir())
91    }
92
93    /// Lux cache directory
94    fn default_cache_path() -> PathBuf {
95        match Config::project_dirs() {
96            Ok(project_dirs) => project_dirs.cache_dir().to_path_buf(),
97            Err(_) => Self::fallback_data_dir("cache"),
98        }
99    }
100
101    /// Lux data directory
102    fn default_data_path() -> PathBuf {
103        match Config::project_dirs() {
104            Ok(project_dirs) => project_dirs.data_local_dir().to_path_buf(),
105            Err(_) => Self::fallback_data_dir("data"),
106        }
107    }
108
109    /// Fallback data directory used when no valid home directory can be determined.
110    fn fallback_data_dir(subdir: &str) -> PathBuf {
111        let path = std::env::temp_dir().join("lux").join(subdir);
112        tracing::warn!(
113            "no valid home directory found; falling back to `{}`",
114            path.display()
115        );
116        path
117    }
118
119    /// Create a copy of this config for the specified Lua version
120    pub fn with_lua_version(self, lua_version: LuaVersion) -> Self {
121        Self {
122            lua_version: Some(lua_version),
123            ..self
124        }
125    }
126
127    /// Create a copy of this config with the specified install tree
128    pub fn with_tree(self, tree: PathBuf) -> Self {
129        Self {
130            user_tree: tree,
131            ..self
132        }
133    }
134
135    /// Create a copy of this config with the specified workspace tree root
136    pub fn with_workspace_tree(self, tree: Option<PathBuf>) -> Self {
137        Self {
138            workspace_tree: tree,
139            ..self
140        }
141    }
142
143    /// The luarocks repository server
144    pub fn server(&self) -> &Url {
145        &self.server
146    }
147
148    /// Additional luarocks repository servers
149    pub fn extra_servers(&self) -> &Vec<Url> {
150        self.extra_servers.as_ref()
151    }
152
153    /// The base URL of the precomputed manifest cache.
154    pub fn manifest_cache_url(&self) -> Option<&Url> {
155        self.manifest_cache_url.as_ref()
156    }
157
158    /// Additional wally package registries (git-index URLs), in addition to the
159    /// official wally index.
160    pub fn extra_wally_registries(&self) -> &Vec<Url> {
161        self.extra_wally_registries.as_ref()
162    }
163
164    /// Enabled luarocks repository servers that provide dev/scm rocks
165    pub fn enabled_dev_servers(&self) -> Result<Vec<Url>, ConfigError> {
166        let mut enabled_dev_servers = Vec::new();
167        if self.enable_development_packages {
168            let config_file = ConfigBuilder::config_file()
169                .map(|p| p.to_string_lossy().to_string())
170                .unwrap_or_default();
171            enabled_dev_servers.push(self.server().join(DEV_PATH).map_err(|source| {
172                ConfigError::UrlParseError {
173                    source,
174                    help: Some(format!("check the `server` URL in {config_file}")),
175                }
176            })?);
177            for server in self.extra_servers() {
178                enabled_dev_servers.push(server.join(DEV_PATH).map_err(|source| {
179                    ConfigError::UrlParseError {
180                        source,
181                        help: Some(format!("check the `extra_servers` URLs in {config_file}")),
182                    }
183                })?);
184            }
185        }
186        Ok(enabled_dev_servers)
187    }
188
189    /// The luarocks server namespace to use
190    pub fn namespace(&self) -> Option<&String> {
191        self.namespace.as_ref()
192    }
193
194    /// The directory in which to install Lua{n} if not found
195    pub fn lua_dir(&self) -> Option<&PathBuf> {
196        self.lua_dir.as_ref()
197    }
198
199    // TODO(vhyrro): Remove `LuaVersion::from(&config)` and keep this only.
200    pub fn lua_version(&self) -> Option<&LuaVersion> {
201        self.lua_version.as_ref()
202    }
203
204    /// The tree in which to install rocks.
205    /// If installing packages for a project, use `Project::tree` instead.
206    pub fn user_tree(&self, version: LuaVersion) -> Result<Tree, TreeError> {
207        Tree::new(self.user_tree.clone(), version, self)
208    }
209
210    /// The detached workspace tree root, if set.
211    pub fn workspace_tree(&self) -> Option<&PathBuf> {
212        self.workspace_tree.as_ref()
213    }
214
215    /// Whether to display verbose output of commands executed
216    pub fn verbose(&self) -> bool {
217        self.verbose
218    }
219
220    /// Whether to disable printing progress bars and spinners
221    pub fn no_progress(&self) -> bool {
222        self.no_progress
223    }
224
225    /// Whether to skip prompts, selecting the default option
226    pub fn no_prompt(&self) -> bool {
227        self.no_prompt
228    }
229
230    /// Timeout on network operations, in seconds.
231    /// 0 means no timeout (wait forever).
232    pub fn timeout(&self) -> &Duration {
233        &self.timeout
234    }
235
236    /// Maximum buffer size for parallel jobs, such as downloading rockspecs and installing rocks.
237    /// 0 means no limit.
238    pub fn max_jobs(&self) -> usize {
239        self.max_jobs
240    }
241
242    /// Command to use for running `make` builds
243    pub fn make_cmd(&self) -> String {
244        match self.variables.get("MAKE") {
245            Some(make) => make.clone(),
246            None => "make".into(),
247        }
248    }
249
250    /// Command to use for running `cmake` builds
251    pub fn cmake_cmd(&self) -> String {
252        match self.variables.get("CMAKE") {
253            Some(cmake) => cmake.clone(),
254            None => "cmake".into(),
255        }
256    }
257
258    /// Construct a [`Command`] for the given program and arguments,
259    /// wrapped in the configured [`BuildConfig::runner`], if any.
260    ///
261    /// If no runner is configured, this is equivalent to
262    /// `Command::new(program).args(args)`.
263    pub(crate) fn wrapped_command<P, A>(&self, program: P, args: A) -> Command
264    where
265        P: AsRef<OsStr>,
266        A: IntoIterator,
267        A::Item: AsRef<OsStr>,
268    {
269        if self.build.runner.is_empty() {
270            let mut cmd = Command::new(program);
271            cmd.args(args);
272            cmd
273        } else {
274            let runner = &self.build.runner;
275            let mut cmd = Command::new(&runner[0]);
276            cmd.args(&runner[1..]).arg(program).args(args);
277            cmd
278        }
279    }
280
281    /// The build profile to use when compiling packages.
282    pub(crate) fn build_profile(&self) -> build::Profile {
283        self.build
284            .profile
285            .as_ref()
286            .cloned()
287            .unwrap_or(build::Profile::Release)
288    }
289
290    /// Variable names, mapped to their values.
291    /// Lux populates variables in the `lux.toml` and in RockSpecs
292    /// with these before building.
293    pub fn variables(&self) -> &HashMap<String, String> {
294        &self.variables
295    }
296
297    /// The access token configured for the given host, if any.
298    /// Precedence: the `LUX_ACCESS_TOKENS` environment variable, then the
299    /// `[access_tokens]` section of the config file, then the `GITHUB_TOKEN`
300    /// environment variable for `github.com`.
301    pub fn access_token(&self, host: &str) -> Option<AccessToken> {
302        access_tokens::env_access_tokens()
303            .get(host)
304            .cloned()
305            .or_else(|| self.access_tokens.get(host).cloned())
306            .or_else(|| {
307                (host == "github.com")
308                    .then(|| env::var("GITHUB_TOKEN").ok())
309                    .flatten()
310                    .map(|raw| AccessToken::from(raw.as_str()))
311            })
312    }
313
314    pub fn external_deps(&self) -> &ExternalDependencySearchConfig {
315        &self.external_deps
316    }
317
318    /// The custom layout applied to entrypoint packages, if any.
319    pub fn entrypoint_layout(&self) -> Option<&Arc<dyn CustomRockLayout>> {
320        self.entrypoint_layout.as_ref()
321    }
322
323    /// The Lux cache directory
324    pub fn cache_dir(&self) -> &PathBuf {
325        &self.cache_dir
326    }
327
328    /// The Lux data directory
329    pub fn data_dir(&self) -> &PathBuf {
330        &self.data_dir
331    }
332
333    /// Specifies a directory with locally vendored sources and RockSpecs.
334    /// When building or installing a package with this flag,
335    /// Lux will fetch sources from the <vendor-dir> instead of from a remote server.
336    pub fn vendor_dir(&self) -> Option<&PathBuf> {
337        self.vendor_dir.as_ref()
338    }
339
340    /// The user agent to use when making web requests.
341    pub fn user_agent(&self) -> &str {
342        &self.user_agent
343    }
344
345    /// Whether to generate a `.luarc.json` on build.
346    pub fn generate_luarc(&self) -> bool {
347        self.generate_luarc
348    }
349
350    // Lua runtime configuration file name
351    pub fn luarc_file_name(&self) -> &str {
352        &self.luarc_file_name
353    }
354
355    /// Whether to wrap installed Lua bin scripts to be executed with
356    /// the detected or configured Lua installation.
357    /// If `true`, individual rocks can still disable wrapping of their own bin scripts.
358    pub fn wrap_bin_scripts(&self) -> bool {
359        self.wrap_bin_scripts
360    }
361
362    /// Filter specification for package types to include in searches.
363    pub fn package_types(&self) -> &RemotePackageTypeFilterSpec {
364        &self.package_types
365    }
366    /// Whether to disable prompts for two-factor authentication (2FA) codes.
367    pub fn no_tfa(&self) -> bool {
368        self.no_tfa
369    }
370}
371
372impl HasVariables for Config {
373    #[tracing::instrument(level = "trace")]
374    fn get_variable(&self, input: &str) -> Result<Option<String>, GetVariableError> {
375        Ok(self.variables.get(input).cloned())
376    }
377}
378
379#[derive(Error, Debug, Diagnostic)]
380pub enum ConfigError {
381    #[error(transparent)]
382    #[diagnostic(transparent)]
383    Fs(#[from] fs::FsError),
384    #[error(transparent)]
385    #[diagnostic(transparent)]
386    NoValidHomeDirectory(#[from] NoValidHomeDirectory),
387    #[error("error parsing {config_file}")]
388    Deserialize {
389        config_file: String,
390        #[diagnostic_source]
391        source: TomlDeError,
392    },
393    #[error("error parsing URL: {source}")]
394    UrlParseError {
395        source: url::ParseError,
396        #[help]
397        help: Option<String>,
398    },
399}
400
401/// Incrementally builds a [`Config`] by layering configuration sources.
402///
403/// - Call [`ConfigBuilder::default`] to start with a blank slate,
404///   or call [`ConfigBuilder::new`] to start from a deserialised configuration file.
405/// - Populate the fields from overriding sources (e.g. CLI arguments).
406/// - Finish with [`ConfigBuilder::build`].
407#[derive(Debug, Clone, Default, Deserialize, Serialize)]
408#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
409pub struct ConfigBuilder {
410    /// The LuaRocks repository server to fetch rocks and rockspecs from.
411    /// Default: "https://luarocks.org/".
412    #[serde(
413        default,
414        deserialize_with = "deserialize_url",
415        serialize_with = "serialize_url"
416    )]
417    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
418    server: Option<Url>,
419    /// Additional LuaRocks repository servers.
420    #[serde(
421        default,
422        deserialize_with = "deserialize_url_vec",
423        serialize_with = "serialize_url_vec"
424    )]
425    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<String>>"))]
426    extra_servers: Option<Vec<Url>>,
427    #[serde(
428        default,
429        deserialize_with = "deserialize_url",
430        serialize_with = "serialize_url"
431    )]
432    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
433    manifest_cache_url: Option<Url>,
434    no_manifest_cache: Option<bool>,
435    /// Additional wally package registries (git-index URLs), in addition to the
436    /// official wally index.
437    #[serde(
438        default,
439        deserialize_with = "deserialize_url_vec",
440        serialize_with = "serialize_url_vec"
441    )]
442    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<String>>"))]
443    extra_wally_registries: Option<Vec<Url>>,
444    /// The LuaRocks server namespace to use.
445    namespace: Option<String>,
446    /// The Lua version to use. Default: the detected installed Lua version.
447    lua_version: Option<LuaVersion>,
448    /// The tree in which to install rocks.
449    user_tree: Option<PathBuf>,
450    /// The tree to use when in a workspace.
451    /// Default: a ".lux" directory in the workspace root.
452    workspace_tree: Option<PathBuf>,
453    /// The directory in which to install Lua if it is not found.
454    lua_dir: Option<PathBuf>,
455    /// The cache directory, e.g. for LuaRocks manifests.
456    cache_dir: Option<PathBuf>,
457    /// The data directory, in which the default user install tree resides.
458    data_dir: Option<PathBuf>,
459    /// A directory with locally vendored sources and rockspecs, used instead of
460    /// a remote server.
461    vendor_dir: Option<PathBuf>,
462    /// Whether to fetch development/scm rocks. Default: `false`.
463    enable_development_packages: Option<bool>,
464    /// Whether to display verbose output of executed commands. Default: `false`.
465    verbose: Option<bool>,
466    /// Whether to disable progress bars and spinners. Default: `false`.
467    no_progress: Option<bool>,
468    /// Whether to skip prompts, selecting the default option. Default: `false`.
469    no_prompt: Option<bool>,
470    /// Timeout for network operations, in seconds. `0` disables the timeout.
471    /// Default: `30`.
472    #[cfg_attr(feature = "schema", schemars(with = "Option<u64>"))]
473    timeout: Option<Duration>,
474    /// Maximum number of parallel jobs, e.g. for downloads and installs.
475    /// `0` means no limit.
476    max_jobs: Option<usize>,
477    /// Variable names mapped to their values. Lux substitutes these in the
478    /// `lux.toml` and in rockspecs before building.
479    variables: Option<HashMap<String, String>>,
480    /// Access tokens for fetching sources from private hosts, mapped by host.
481    /// These can also be set via the `LUX_ACCESS_TOKENS` environment variable.
482    #[serde(default, skip_serializing)]
483    #[cfg_attr(feature = "schema", schemars(with = "Option<HashMap<String, String>>"))]
484    access_tokens: Option<HashMap<String, AccessToken>>,
485    #[serde(default)]
486    external_deps: ExternalDependencySearchConfig,
487    #[serde(default)]
488    build: BuildConfig,
489
490    #[serde(skip)]
491    entrypoint_layout: Option<Arc<dyn CustomRockLayout>>,
492    /// The user agent to use when making web requests.
493    /// Default: "lux/<version>" (CLI), "lux-lua/<version>" (lux-lua) or "lux-lib/<version>".
494    user_agent: Option<String>,
495    /// Whether to generate a ".luarc.json" on build. Default: `true`.
496    generate_luarc: Option<bool>,
497    /// The Lua language server configuration file name. Default: ".luarc.json".
498    luarc_file_name: Option<String>,
499    /// Whether to wrap installed Lua bin scripts to run with the detected or
500    /// configured Lua installation. Default: `true`.
501    wrap_bin_scripts: Option<bool>,
502    /// Which package types to include in searches.
503    package_types: Option<RemotePackageTypeFilterSpec>,
504    /// Whether to disable prompts for two-factor authentication (2FA) codes.
505    /// Default: `false`.
506    no_tfa: Option<bool>,
507}
508
509/// A builder for the lux `Config`.
510impl ConfigBuilder {
511    /// Create a new `ConfigBuilder` by deserializing from a config file
512    /// if present, or otherwise by instantiating the default config.
513    pub fn new() -> Result<Self, ConfigError> {
514        let config_file = Self::config_file()?;
515        if config_file.is_file() {
516            Self::from_file(&config_file)
517        } else {
518            Ok(Self::default())
519        }
520    }
521
522    pub(crate) fn from_file(config_file: &Path) -> Result<Self, ConfigError> {
523        let config_file_name = config_file.to_string_lossy().to_string();
524        let content = fs::sync::read_to_string(config_file)?;
525        crate::project::parse_toml(&config_file_name, &content).map_err(|source| {
526            ConfigError::Deserialize {
527                config_file: config_file_name,
528                source,
529            }
530        })
531    }
532
533    /// Get the path to the lux config file.
534    pub fn config_file() -> Result<PathBuf, NoValidHomeDirectory> {
535        let project_dirs = directories::ProjectDirs::from("org", "lumenlabs", "lux")
536            .ok_or(NoValidHomeDirectory)?;
537        Ok(project_dirs.config_dir().join("config.toml").to_path_buf())
538    }
539
540    /// Whether to enable development packages
541    /// Default: `false`
542    pub fn dev(self, dev: Option<bool>) -> Self {
543        Self {
544            enable_development_packages: dev.or(self.enable_development_packages),
545            ..self
546        }
547    }
548
549    /// Fetch rocks/rockspecs from this luarocks server
550    /// Default: `"https://luarocks.org/"`
551    pub fn server(self, server: Option<Url>) -> Self {
552        Self {
553            server: server.or(self.server),
554            ..self
555        }
556    }
557
558    /// Fetch rocks/rockspecs from these servers in addition to the main server
559    pub fn extra_servers(self, extra_servers: Option<Vec<Url>>) -> Self {
560        Self {
561            extra_servers: extra_servers.or(self.extra_servers),
562            ..self
563        }
564    }
565
566    /// The base URL of the precomputed manifest cache.
567    /// Default is https://cache.luanox.org/
568    pub fn manifest_cache_url(self, manifest_cache_url: Option<Url>) -> Self {
569        Self {
570            manifest_cache_url: manifest_cache_url.or(self.manifest_cache_url),
571            ..self
572        }
573    }
574
575    /// Whether to disable the precomputed manifest cache.
576    /// Default: `false`
577    pub fn no_manifest_cache(self, no_manifest_cache: Option<bool>) -> Self {
578        Self {
579            no_manifest_cache: no_manifest_cache.or(self.no_manifest_cache),
580            ..self
581        }
582    }
583
584    /// Additional wally package registries, in addition to the official wally index
585    pub fn extra_wally_registries(self, extra_wally_registries: Option<Vec<Url>>) -> Self {
586        Self {
587            extra_wally_registries: extra_wally_registries.or(self.extra_wally_registries),
588            ..self
589        }
590    }
591
592    /// The luarocks server namespace to use
593    pub fn namespace(self, namespace: Option<String>) -> Self {
594        Self {
595            namespace: namespace.or(self.namespace),
596            ..self
597        }
598    }
599
600    /// The directory in which to install Lua if not found
601    pub fn lua_dir(self, lua_dir: Option<PathBuf>) -> Self {
602        Self {
603            lua_dir: lua_dir.or(self.lua_dir),
604            ..self
605        }
606    }
607
608    /// Which Lua version to use.
609    /// Default: The installed Lua version, if detected
610    pub fn lua_version(self, lua_version: Option<LuaVersion>) -> Self {
611        Self {
612            lua_version: lua_version.or(self.lua_version),
613            ..self
614        }
615    }
616
617    /// Which tree to operate on
618    pub fn user_tree(self, tree: Option<PathBuf>) -> Self {
619        Self {
620            user_tree: tree.or(self.user_tree),
621            ..self
622        }
623    }
624
625    /// Which tree to operate on when in a workspace
626    /// Default: A `.lux` directory in the workspace root.
627    pub fn workspace_tree(self, tree: Option<PathBuf>) -> Self {
628        Self {
629            workspace_tree: tree.or(self.workspace_tree),
630            ..self
631        }
632    }
633
634    /// Variable names, mapped to their values.
635    /// Lux populates variables in the `lux.toml` and in RockSpecs
636    /// with these before building.
637    pub fn variables(self, variables: Option<HashMap<String, String>>) -> Self {
638        Self {
639            variables: variables.or(self.variables),
640            ..self
641        }
642    }
643
644    /// Whether to display verbose output of commands executed.
645    /// Default: `false`
646    pub fn verbose(self, verbose: Option<bool>) -> Self {
647        Self {
648            verbose: verbose.or(self.verbose),
649            ..self
650        }
651    }
652
653    /// Whether to disable printing progress bars and spinners
654    /// Default: `false`
655    pub fn no_progress(self, no_progress: Option<bool>) -> Self {
656        Self {
657            no_progress: no_progress.or(self.no_progress),
658            ..self
659        }
660    }
661
662    /// Whether to disable user prompts
663    /// Default: `false`
664    pub fn no_prompt(self, no_prompt: Option<bool>) -> Self {
665        Self {
666            no_prompt: no_prompt.or(self.no_prompt),
667            ..self
668        }
669    }
670
671    /// Timeout on network operations, in seconds.
672    /// 0 means no timeout (wait forever).
673    /// Default: 30 s
674    pub fn timeout(self, timeout: Option<Duration>) -> Self {
675        Self {
676            timeout: timeout.or(self.timeout),
677            ..self
678        }
679    }
680
681    /// Maximum buffer size for parallel jobs, such as downloading rockspecs and installing rocks.
682    /// 0 means no limit.
683    /// Default: 0
684    pub fn max_jobs(self, max_jobs: Option<usize>) -> Self {
685        Self {
686            max_jobs: max_jobs.or(self.max_jobs),
687            ..self
688        }
689    }
690
691    /// The cache directory, e.g. for luarocks manifests.
692    pub fn cache_dir(self, cache_dir: Option<PathBuf>) -> Self {
693        Self {
694            cache_dir: cache_dir.or(self.cache_dir),
695            ..self
696        }
697    }
698
699    /// The data directory, in which the default user install tree resides.
700    pub fn data_dir(self, data_dir: Option<PathBuf>) -> Self {
701        Self {
702            data_dir: data_dir.or(self.data_dir),
703            ..self
704        }
705    }
706
707    /// Specifies a directory with locally vendored sources and RockSpecs.
708    /// When building or installing a package with this flag,
709    /// Lux will fetch sources from the <vendor-dir> instead of from a remote server.
710    pub fn vendor_dir(self, vendor_dir: Option<PathBuf>) -> Self {
711        Self {
712            vendor_dir: vendor_dir.or(self.vendor_dir),
713            ..self
714        }
715    }
716
717    /// The custom layout to apply to entrypoint packages.
718    pub fn entrypoint_layout(self, layout: impl CustomRockLayout + 'static) -> Self {
719        Self {
720            entrypoint_layout: Some(Arc::new(layout)),
721            ..self
722        }
723    }
724
725    /// The user agent to set when making web requests.
726    /// Default: "lux-lib/<version>".
727    pub fn user_agent(self, user_agent: Option<String>) -> Self {
728        Self {
729            user_agent: user_agent.or(self.user_agent),
730            ..self
731        }
732    }
733
734    /// Whether to generate a `.luarc.json` on build.
735    /// Default: `true`
736    pub fn generate_luarc(self, generate: Option<bool>) -> Self {
737        Self {
738            generate_luarc: generate.or(self.generate_luarc),
739            ..self
740        }
741    }
742
743    /// Lua runtime configuration file name
744    /// Default: `.luarc.json`
745    pub fn luarc_file_name(self, file: Option<String>) -> Self {
746        Self {
747            luarc_file_name: file.or(self.luarc_file_name),
748            ..self
749        }
750    }
751
752    /// Whether to wrap installed Lua bin scripts to be executed with
753    /// the detected or configured Lua installation.
754    /// Setting this to `false` disables wrapping globally.
755    /// If set to `true`, individual rocks can still disable wrapping of their own bin scripts.
756    /// Default: `true`.
757    pub fn wrap_bin_scripts(self, generate: Option<bool>) -> Self {
758        Self {
759            wrap_bin_scripts: generate.or(self.generate_luarc),
760            ..self
761        }
762    }
763    /// Whether to disable prompts for two-factor authentication (2FA) codes.
764    /// Default: `false`.
765    pub fn no_tfa(self, tfa: Option<bool>) -> Self {
766        Self {
767            no_tfa: tfa.or(self.no_tfa),
768            ..self
769        }
770    }
771
772    /// The command prefix with which to wrap all build commands.
773    ///
774    /// If set, every command spawned by the build backends (`make`, `cmake`,
775    /// `rust-mlua`, `command`, and `luarocks`) is invoked as
776    /// `runner + [command, arguments...]`.
777    ///
778    /// If unset, no wrapping is performed.
779    ///
780    /// # Examples
781    ///
782    /// Use [`bubblewrap`](https://github.com/containers/bubblewrap) to run
783    /// builds in a sandbox with read-only access to the rest of the
784    /// filesystem (Linux):
785    ///
786    /// ```toml
787    /// [build]
788    /// runner = [
789    ///     "bwrap",
790    ///     "--ro-bind", "/", "/",
791    ///     "--dev-bind", "/dev", "/dev",
792    ///     "--proc", "/proc",
793    ///     "--bind", "/tmp", "/tmp",
794    ///     "--bind", "/home/user/.cache/lux", "/home/user/.cache/lux",
795    ///     "--bind", "/home/user/.local/share/lux", "/home/user/.local/share/lux",
796    ///     "--unshare-net",
797    ///     "--new-session",
798    /// ]
799    /// ```
800    ///
801    /// Or use `sandbox-exec` with a seatbelt profile (macOS):
802    ///
803    /// ```text
804    /// (version 1)
805    /// (deny default)
806    /// (allow file-read*)
807    /// (allow process*)
808    /// (allow sysctl-read)
809    /// (allow file-write* (subpath "/tmp") (subpath "/Users/user/Library/Caches/lux") (subpath "/Users/user/.local/share/lux"))
810    /// ```
811    ///
812    /// ```toml
813    /// [build]
814    /// runner = ["sandbox-exec", "-f", "/Users/user/sandbox.sb"]
815    /// ```
816    ///
817    /// The writable directories must cover the places lux writes to during a
818    /// build: the temporary build directory (a tempfile, usually under
819    /// `/tmp`), the install tree (under the data directory), and cache
820    /// directories used by the various build backends.
821    pub fn build_runner(self, runner: Option<Vec<String>>) -> Self {
822        Self {
823            build: BuildConfig {
824                runner: runner.unwrap_or(self.build.runner),
825                ..self.build
826            },
827            ..self
828        }
829    }
830
831    /// The build profile to use when compiling packages.
832    /// Default: [`BuildProfile::Release`], if not set by [`Self::default_build_profile`].
833    pub fn build_profile(self, profile: Option<build::Profile>) -> Self {
834        Self {
835            build: BuildConfig {
836                profile: profile.or(self.build.profile),
837                ..self.build
838            },
839            ..self
840        }
841    }
842
843    /// set the default build profile to use when compiling packages.
844    pub fn default_build_profile(self, profile: build::Profile) -> Self {
845        Self {
846            build: BuildConfig {
847                profile: self.build.profile.or(Some(profile)),
848                ..self.build
849            },
850            ..self
851        }
852    }
853
854    /// Merge with another [`ConfigBuilder`]. The other one takes precedence.
855    pub fn merge(self, other: Self) -> Self {
856        Self {
857            server: other.server.or(self.server),
858            extra_servers: other.extra_servers.or(self.extra_servers),
859            manifest_cache_url: other.manifest_cache_url.or(self.manifest_cache_url),
860            no_manifest_cache: other.no_manifest_cache.or(self.no_manifest_cache),
861            extra_wally_registries: other.extra_wally_registries.or(self.extra_wally_registries),
862            namespace: other.namespace.or(self.namespace),
863            lua_version: other.lua_version.or(self.lua_version),
864            user_tree: other.user_tree.or(self.user_tree),
865            workspace_tree: other.workspace_tree.or(self.workspace_tree),
866            lua_dir: other.lua_dir.or(self.lua_dir),
867            cache_dir: other.cache_dir.or(self.cache_dir),
868            data_dir: other.data_dir.or(self.data_dir),
869            vendor_dir: other.vendor_dir.or(self.vendor_dir),
870            enable_development_packages: other
871                .enable_development_packages
872                .or(self.enable_development_packages),
873            verbose: other.verbose.or(self.verbose),
874            no_progress: other.no_progress.or(self.no_progress),
875            no_prompt: other.no_prompt.or(self.no_prompt),
876            timeout: other.timeout.or(self.timeout),
877            max_jobs: other.max_jobs.or(self.max_jobs),
878            variables: other.variables.or(self.variables),
879            access_tokens: other.access_tokens.or(self.access_tokens),
880            external_deps: other.external_deps,
881            build: BuildConfig {
882                profile: other.build.profile.or(self.build.profile),
883                ..other.build
884            },
885            entrypoint_layout: other.entrypoint_layout.or(self.entrypoint_layout),
886            user_agent: other.user_agent.or(self.user_agent),
887            generate_luarc: other.generate_luarc.or(self.generate_luarc),
888            luarc_file_name: other.luarc_file_name.or(self.luarc_file_name),
889            wrap_bin_scripts: other.wrap_bin_scripts.or(self.wrap_bin_scripts),
890            package_types: other.package_types.or(self.package_types),
891            no_tfa: other.no_tfa.or(self.no_tfa),
892        }
893    }
894
895    #[tracing::instrument(level = "trace")]
896    pub fn build(self) -> Result<Config, ConfigError> {
897        let data_dir = self.data_dir.unwrap_or(Config::default_data_path());
898        let cache_dir = self.cache_dir.unwrap_or(Config::default_cache_path());
899        let user_tree = self.user_tree.unwrap_or(data_dir.join("tree"));
900
901        let lua_version = self
902            .lua_version
903            .or(crate::lua_installation::detect_installed_lua_version());
904
905        Ok(normalise_base_urls(Config {
906            enable_development_packages: self.enable_development_packages.unwrap_or(false),
907            server: self.server.unwrap_or_else(|| unsafe {
908                Url::parse("https://luarocks.org/").unwrap_unchecked()
909            }),
910            extra_servers: self.extra_servers.unwrap_or_default(),
911            manifest_cache_url: if self.no_manifest_cache.unwrap_or(false) {
912                None
913            } else {
914                self.manifest_cache_url.or_else(|| unsafe {
915                    Some(Url::parse("https://cache.luanox.org/").unwrap_unchecked())
916                })
917            },
918            extra_wally_registries: self.extra_wally_registries.unwrap_or_default(),
919            namespace: self.namespace,
920            lua_dir: self.lua_dir,
921            lua_version,
922            user_tree,
923            workspace_tree: self.workspace_tree,
924            verbose: self.verbose.unwrap_or(false),
925            no_progress: self.no_progress.unwrap_or(false),
926            no_prompt: self.no_prompt.unwrap_or(false),
927            timeout: self.timeout.unwrap_or_else(|| Duration::from_secs(30)),
928            max_jobs: match self.max_jobs.unwrap_or_else(|| {
929                std::thread::available_parallelism().map_or(1, |available| available.get())
930            }) {
931                0 => usize::MAX,
932                max_jobs => max_jobs,
933            },
934            variables: default_variables()
935                .chain(self.variables.unwrap_or_default())
936                .collect(),
937            access_tokens: self.access_tokens.unwrap_or_default(),
938            external_deps: self.external_deps,
939            build: self.build,
940            entrypoint_layout: self.entrypoint_layout,
941            cache_dir,
942            data_dir,
943            vendor_dir: self.vendor_dir,
944            user_agent: self.user_agent.unwrap_or(DEFAULT_USER_AGENT.into()),
945            generate_luarc: self.generate_luarc.unwrap_or(true),
946            luarc_file_name: self
947                .luarc_file_name
948                .unwrap_or_else(|| ".luarc.json".to_string()),
949            wrap_bin_scripts: self.wrap_bin_scripts.unwrap_or(true),
950            package_types: self.package_types.unwrap_or_default(),
951            no_tfa: self.no_tfa.unwrap_or(false),
952        }))
953    }
954}
955
956/// Useful for printing the current config
957impl From<Config> for ConfigBuilder {
958    fn from(value: Config) -> Self {
959        let no_manifest_cache = Some(value.manifest_cache_url.is_none());
960        ConfigBuilder {
961            enable_development_packages: Some(value.enable_development_packages),
962            server: Some(value.server),
963            extra_servers: Some(value.extra_servers),
964            manifest_cache_url: value.manifest_cache_url,
965            no_manifest_cache,
966            extra_wally_registries: Some(value.extra_wally_registries),
967            namespace: value.namespace,
968            lua_dir: value.lua_dir,
969            lua_version: value.lua_version,
970            user_tree: Some(value.user_tree),
971            workspace_tree: value.workspace_tree,
972            verbose: Some(value.verbose),
973            no_progress: Some(value.no_progress),
974            no_prompt: Some(value.no_prompt),
975            timeout: Some(value.timeout),
976            max_jobs: if value.max_jobs == usize::MAX {
977                None
978            } else {
979                Some(value.max_jobs)
980            },
981            variables: Some(value.variables),
982            access_tokens: Some(value.access_tokens),
983            cache_dir: Some(value.cache_dir),
984            data_dir: Some(value.data_dir),
985            vendor_dir: value.vendor_dir,
986            external_deps: value.external_deps,
987            build: value.build,
988            entrypoint_layout: value.entrypoint_layout,
989            user_agent: Some(value.user_agent),
990            generate_luarc: Some(value.generate_luarc),
991            luarc_file_name: Some(value.luarc_file_name),
992            wrap_bin_scripts: Some(value.wrap_bin_scripts),
993            package_types: Some(value.package_types),
994            no_tfa: Some(value.no_tfa),
995        }
996    }
997}
998
999fn default_variables() -> impl Iterator<Item = (String, String)> {
1000    let cflags = env::var("CFLAGS").unwrap_or(utils::default_cflags().into());
1001    let ldflags = env::var("LDFLAGS").unwrap_or("".into());
1002    vec![
1003        ("MAKE".into(), "make".into()),
1004        ("CMAKE".into(), "cmake".into()),
1005        ("LIB_EXTENSION".into(), utils::c_dylib_extension().into()),
1006        ("OBJ_EXTENSION".into(), utils::c_obj_extension().into()),
1007        ("CFLAGS".into(), cflags),
1008        ("LDFLAGS".into(), ldflags),
1009        ("LIBFLAG".into(), utils::default_libflag().into()),
1010    ]
1011    .into_iter()
1012}
1013
1014fn normalise_base_url(mut url: Url) -> Url {
1015    if !url.path().ends_with('/') {
1016        url.set_path(&format!("{}/", url.path()));
1017    }
1018    url
1019}
1020
1021fn normalise_base_urls(mut config: Config) -> Config {
1022    config.server = normalise_base_url(config.server);
1023    config.extra_servers = config
1024        .extra_servers
1025        .into_iter()
1026        .map(normalise_base_url)
1027        .collect();
1028    config.manifest_cache_url = config.manifest_cache_url.map(normalise_base_url);
1029    config
1030}
1031
1032fn deserialize_url<'de, D>(deserializer: D) -> Result<Option<Url>, D::Error>
1033where
1034    D: serde::Deserializer<'de>,
1035{
1036    let s = Option::<String>::deserialize(deserializer)?;
1037    s.map(|s| Url::parse(&s).map_err(serde::de::Error::custom))
1038        .transpose()
1039}
1040
1041fn serialize_url<S>(url: &Option<Url>, serializer: S) -> Result<S::Ok, S::Error>
1042where
1043    S: Serializer,
1044{
1045    match url {
1046        Some(url) => serializer.serialize_some(url.as_str()),
1047        None => serializer.serialize_none(),
1048    }
1049}
1050
1051fn deserialize_url_vec<'de, D>(deserializer: D) -> Result<Option<Vec<Url>>, D::Error>
1052where
1053    D: serde::Deserializer<'de>,
1054{
1055    let s = Option::<Vec<String>>::deserialize(deserializer)?;
1056    s.map(|v| {
1057        v.into_iter()
1058            .map(|s| Url::parse(&s).map_err(serde::de::Error::custom))
1059            .try_collect()
1060    })
1061    .transpose()
1062}
1063
1064fn serialize_url_vec<S>(urls: &Option<Vec<Url>>, serializer: S) -> Result<S::Ok, S::Error>
1065where
1066    S: Serializer,
1067{
1068    match urls {
1069        Some(urls) => {
1070            let url_strings: Vec<String> = urls.iter().map(|url| url.to_string()).collect();
1071            serializer.serialize_some(&url_strings)
1072        }
1073        None => serializer.serialize_none(),
1074    }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use super::*;
1080
1081    #[tokio::test]
1082    async fn wrapped_command_without_runner() {
1083        let echo = which::which("echo").unwrap();
1084        let config = ConfigBuilder::default().build().unwrap();
1085        let output = config
1086            .wrapped_command(&echo, ["hello", "world"])
1087            .output()
1088            .await
1089            .unwrap();
1090        assert!(output.status.success());
1091        assert_eq!(
1092            String::from_utf8_lossy(&output.stdout).trim(),
1093            "hello world"
1094        );
1095    }
1096
1097    #[tokio::test]
1098    async fn wrapped_command_prepends_runner_argv() {
1099        let echo = which::which("echo").unwrap();
1100        let config = ConfigBuilder::default()
1101            .build_runner(Some(vec![echo.to_string_lossy().into(), "--prefix".into()]))
1102            .build()
1103            .unwrap();
1104        let output = config
1105            .wrapped_command("world", Vec::<String>::new())
1106            .output()
1107            .await
1108            .unwrap();
1109        assert!(output.status.success());
1110        assert_eq!(
1111            String::from_utf8_lossy(&output.stdout).trim(),
1112            "--prefix world"
1113        );
1114    }
1115
1116    #[test]
1117    fn base_urls_resolve_relative_paths() {
1118        let config = ConfigBuilder::default()
1119            .server(Some(Url::parse("https://example.com/texrocks").unwrap()))
1120            .extra_servers(Some(vec![
1121                Url::parse("https://example.com/texrocks").unwrap()
1122            ]))
1123            .build()
1124            .unwrap();
1125
1126        assert_eq!(
1127            config.server().join("manifest-5.1.zip").unwrap().as_str(),
1128            "https://example.com/texrocks/manifest-5.1.zip"
1129        );
1130        assert_eq!(
1131            config.extra_servers()[0]
1132                .join("manifest-5.1.zip")
1133                .unwrap()
1134                .as_str(),
1135            "https://example.com/texrocks/manifest-5.1.zip"
1136        );
1137
1138        let rebuilt = ConfigBuilder::from(config.clone()).build().unwrap();
1139        assert_eq!(rebuilt.server(), config.server());
1140        assert_eq!(rebuilt.extra_servers(), config.extra_servers());
1141    }
1142
1143    #[test]
1144    fn debug_redacts_access_tokens() {
1145        let config: Config =
1146            toml::from_str::<ConfigBuilder>("[access_tokens]\n\"github.com\" = \"supersecret\"\n")
1147                .unwrap()
1148                .build()
1149                .unwrap();
1150        let debug = format!("{config:?}");
1151        assert!(!debug.contains("supersecret"));
1152        assert!(debug.contains("access_tokens"));
1153    }
1154}