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#[derive(Debug, Clone)]
43pub struct Config {
44 enable_development_packages: bool,
45 server: Url,
46 extra_servers: Vec<Url>,
47 namespace: Option<String>,
48 lua_dir: Option<PathBuf>,
49 lua_version: Option<LuaVersion>,
50 user_tree: PathBuf,
51 workspace_tree: Option<PathBuf>,
52 verbose: bool,
53 no_progress: bool,
55 no_prompt: bool,
57 timeout: Duration,
58 max_jobs: usize,
59 variables: HashMap<String, String>,
60 access_tokens: HashMap<String, AccessToken>,
61 external_deps: ExternalDependencySearchConfig,
62
63 build: BuildConfig,
64 entrypoint_layout: Option<Arc<dyn CustomRockLayout>>,
65
66 cache_dir: PathBuf,
67 data_dir: PathBuf,
68 vendor_dir: Option<PathBuf>,
69
70 user_agent: String,
71
72 generate_luarc: bool,
73 luarc_file_name: String,
74 wrap_bin_scripts: bool,
75 package_types: RemotePackageTypeFilterSpec,
76 no_tfa: bool,
77}
78
79impl Config {
80 pub(crate) fn project_dirs() -> Result<ProjectDirs, NoValidHomeDirectory> {
82 directories::ProjectDirs::from("org", "lumenlabs", "lux")
83 .filter(|_| Self::home_dir_exists())
84 .ok_or(NoValidHomeDirectory)
85 }
86
87 fn home_dir_exists() -> bool {
88 directories::BaseDirs::new().is_some_and(|base| base.home_dir().is_dir())
89 }
90
91 fn default_cache_path() -> PathBuf {
93 match Config::project_dirs() {
94 Ok(project_dirs) => project_dirs.cache_dir().to_path_buf(),
95 Err(_) => Self::fallback_data_dir("cache"),
96 }
97 }
98
99 fn default_data_path() -> PathBuf {
101 match Config::project_dirs() {
102 Ok(project_dirs) => project_dirs.data_local_dir().to_path_buf(),
103 Err(_) => Self::fallback_data_dir("data"),
104 }
105 }
106
107 fn fallback_data_dir(subdir: &str) -> PathBuf {
109 let path = std::env::temp_dir().join("lux").join(subdir);
110 tracing::warn!(
111 "no valid home directory found; falling back to `{}`",
112 path.display()
113 );
114 path
115 }
116
117 pub fn with_lua_version(self, lua_version: LuaVersion) -> Self {
119 Self {
120 lua_version: Some(lua_version),
121 ..self
122 }
123 }
124
125 pub fn with_tree(self, tree: PathBuf) -> Self {
127 Self {
128 user_tree: tree,
129 ..self
130 }
131 }
132
133 pub fn with_workspace_tree(self, tree: Option<PathBuf>) -> Self {
135 Self {
136 workspace_tree: tree,
137 ..self
138 }
139 }
140
141 pub fn server(&self) -> &Url {
143 &self.server
144 }
145
146 pub fn extra_servers(&self) -> &Vec<Url> {
148 self.extra_servers.as_ref()
149 }
150
151 pub fn enabled_dev_servers(&self) -> Result<Vec<Url>, ConfigError> {
153 let mut enabled_dev_servers = Vec::new();
154 if self.enable_development_packages {
155 let config_file = ConfigBuilder::config_file()
156 .map(|p| p.to_string_lossy().to_string())
157 .unwrap_or_default();
158 enabled_dev_servers.push(self.server().join(DEV_PATH).map_err(|source| {
159 ConfigError::UrlParseError {
160 source,
161 help: Some(format!("check the `server` URL in {config_file}")),
162 }
163 })?);
164 for server in self.extra_servers() {
165 enabled_dev_servers.push(server.join(DEV_PATH).map_err(|source| {
166 ConfigError::UrlParseError {
167 source,
168 help: Some(format!("check the `extra_servers` URLs in {config_file}")),
169 }
170 })?);
171 }
172 }
173 Ok(enabled_dev_servers)
174 }
175
176 pub fn namespace(&self) -> Option<&String> {
178 self.namespace.as_ref()
179 }
180
181 pub fn lua_dir(&self) -> Option<&PathBuf> {
183 self.lua_dir.as_ref()
184 }
185
186 pub fn lua_version(&self) -> Option<&LuaVersion> {
188 self.lua_version.as_ref()
189 }
190
191 pub fn user_tree(&self, version: LuaVersion) -> Result<Tree, TreeError> {
194 Tree::new(self.user_tree.clone(), version, self)
195 }
196
197 pub fn workspace_tree(&self) -> Option<&PathBuf> {
199 self.workspace_tree.as_ref()
200 }
201
202 pub fn verbose(&self) -> bool {
204 self.verbose
205 }
206
207 pub fn no_progress(&self) -> bool {
209 self.no_progress
210 }
211
212 pub fn no_prompt(&self) -> bool {
214 self.no_prompt
215 }
216
217 pub fn timeout(&self) -> &Duration {
220 &self.timeout
221 }
222
223 pub fn max_jobs(&self) -> usize {
226 self.max_jobs
227 }
228
229 pub fn make_cmd(&self) -> String {
231 match self.variables.get("MAKE") {
232 Some(make) => make.clone(),
233 None => "make".into(),
234 }
235 }
236
237 pub fn cmake_cmd(&self) -> String {
239 match self.variables.get("CMAKE") {
240 Some(cmake) => cmake.clone(),
241 None => "cmake".into(),
242 }
243 }
244
245 pub(crate) fn wrapped_command<P, A>(&self, program: P, args: A) -> Command
251 where
252 P: AsRef<OsStr>,
253 A: IntoIterator,
254 A::Item: AsRef<OsStr>,
255 {
256 if self.build.runner.is_empty() {
257 let mut cmd = Command::new(program);
258 cmd.args(args);
259 cmd
260 } else {
261 let runner = &self.build.runner;
262 let mut cmd = Command::new(&runner[0]);
263 cmd.args(&runner[1..]).arg(program).args(args);
264 cmd
265 }
266 }
267
268 pub(crate) fn build_profile(&self) -> build::Profile {
270 self.build
271 .profile
272 .as_ref()
273 .cloned()
274 .unwrap_or(build::Profile::Release)
275 }
276
277 pub fn variables(&self) -> &HashMap<String, String> {
281 &self.variables
282 }
283
284 pub fn access_token(&self, host: &str) -> Option<AccessToken> {
289 access_tokens::env_access_tokens()
290 .get(host)
291 .cloned()
292 .or_else(|| self.access_tokens.get(host).cloned())
293 .or_else(|| {
294 (host == "github.com")
295 .then(|| env::var("GITHUB_TOKEN").ok())
296 .flatten()
297 .map(|raw| AccessToken::from(raw.as_str()))
298 })
299 }
300
301 pub fn external_deps(&self) -> &ExternalDependencySearchConfig {
302 &self.external_deps
303 }
304
305 pub fn entrypoint_layout(&self) -> Option<&Arc<dyn CustomRockLayout>> {
307 self.entrypoint_layout.as_ref()
308 }
309
310 pub fn cache_dir(&self) -> &PathBuf {
312 &self.cache_dir
313 }
314
315 pub fn data_dir(&self) -> &PathBuf {
317 &self.data_dir
318 }
319
320 pub fn vendor_dir(&self) -> Option<&PathBuf> {
324 self.vendor_dir.as_ref()
325 }
326
327 pub fn user_agent(&self) -> &str {
329 &self.user_agent
330 }
331
332 pub fn generate_luarc(&self) -> bool {
334 self.generate_luarc
335 }
336
337 pub fn luarc_file_name(&self) -> &str {
339 &self.luarc_file_name
340 }
341
342 pub fn wrap_bin_scripts(&self) -> bool {
346 self.wrap_bin_scripts
347 }
348
349 pub fn package_types(&self) -> &RemotePackageTypeFilterSpec {
351 &self.package_types
352 }
353 pub fn no_tfa(&self) -> bool {
355 self.no_tfa
356 }
357}
358
359impl HasVariables for Config {
360 #[tracing::instrument(level = "trace")]
361 fn get_variable(&self, input: &str) -> Result<Option<String>, GetVariableError> {
362 Ok(self.variables.get(input).cloned())
363 }
364}
365
366#[derive(Error, Debug, Diagnostic)]
367pub enum ConfigError {
368 #[error(transparent)]
369 #[diagnostic(transparent)]
370 Fs(#[from] fs::FsError),
371 #[error(transparent)]
372 #[diagnostic(transparent)]
373 NoValidHomeDirectory(#[from] NoValidHomeDirectory),
374 #[error("error parsing {config_file}")]
375 Deserialize {
376 config_file: String,
377 #[diagnostic_source]
378 source: TomlDeError,
379 },
380 #[error("error parsing URL: {source}")]
381 UrlParseError {
382 source: url::ParseError,
383 #[help]
384 help: Option<String>,
385 },
386}
387
388#[derive(Debug, Clone, Default, Deserialize, Serialize)]
395pub struct ConfigBuilder {
396 #[serde(
397 default,
398 deserialize_with = "deserialize_url",
399 serialize_with = "serialize_url"
400 )]
401 server: Option<Url>,
402 #[serde(
403 default,
404 deserialize_with = "deserialize_url_vec",
405 serialize_with = "serialize_url_vec"
406 )]
407 extra_servers: Option<Vec<Url>>,
408 namespace: Option<String>,
409 lua_version: Option<LuaVersion>,
410 user_tree: Option<PathBuf>,
411 workspace_tree: Option<PathBuf>,
412 lua_dir: Option<PathBuf>,
413 cache_dir: Option<PathBuf>,
414 data_dir: Option<PathBuf>,
415 vendor_dir: Option<PathBuf>,
416 enable_development_packages: Option<bool>,
417 verbose: Option<bool>,
418 no_progress: Option<bool>,
419 no_prompt: Option<bool>,
420 timeout: Option<Duration>,
421 max_jobs: Option<usize>,
422 variables: Option<HashMap<String, String>>,
423 #[serde(default, skip_serializing)]
426 access_tokens: Option<HashMap<String, AccessToken>>,
427 #[serde(default)]
428 external_deps: ExternalDependencySearchConfig,
429 #[serde(default)]
430 build: BuildConfig,
431
432 #[serde(skip)]
433 entrypoint_layout: Option<Arc<dyn CustomRockLayout>>,
434 user_agent: Option<String>,
435 generate_luarc: Option<bool>,
436 luarc_file_name: Option<String>,
437 wrap_bin_scripts: Option<bool>,
438 package_types: Option<RemotePackageTypeFilterSpec>,
439 no_tfa: Option<bool>,
440}
441
442impl ConfigBuilder {
444 pub fn new() -> Result<Self, ConfigError> {
447 let config_file = Self::config_file()?;
448 if config_file.is_file() {
449 Self::from_file(&config_file)
450 } else {
451 Ok(Self::default())
452 }
453 }
454
455 pub(crate) fn from_file(config_file: &Path) -> Result<Self, ConfigError> {
456 let config_file_name = config_file.to_string_lossy().to_string();
457 let content = fs::sync::read_to_string(config_file)?;
458 crate::project::parse_toml(&config_file_name, &content).map_err(|source| {
459 ConfigError::Deserialize {
460 config_file: config_file_name,
461 source,
462 }
463 })
464 }
465
466 pub fn config_file() -> Result<PathBuf, NoValidHomeDirectory> {
468 let project_dirs = directories::ProjectDirs::from("org", "lumenlabs", "lux")
469 .ok_or(NoValidHomeDirectory)?;
470 Ok(project_dirs.config_dir().join("config.toml").to_path_buf())
471 }
472
473 pub fn dev(self, dev: Option<bool>) -> Self {
476 Self {
477 enable_development_packages: dev.or(self.enable_development_packages),
478 ..self
479 }
480 }
481
482 pub fn server(self, server: Option<Url>) -> Self {
485 Self {
486 server: server.or(self.server),
487 ..self
488 }
489 }
490
491 pub fn extra_servers(self, extra_servers: Option<Vec<Url>>) -> Self {
493 Self {
494 extra_servers: extra_servers.or(self.extra_servers),
495 ..self
496 }
497 }
498
499 pub fn namespace(self, namespace: Option<String>) -> Self {
501 Self {
502 namespace: namespace.or(self.namespace),
503 ..self
504 }
505 }
506
507 pub fn lua_dir(self, lua_dir: Option<PathBuf>) -> Self {
509 Self {
510 lua_dir: lua_dir.or(self.lua_dir),
511 ..self
512 }
513 }
514
515 pub fn lua_version(self, lua_version: Option<LuaVersion>) -> Self {
518 Self {
519 lua_version: lua_version.or(self.lua_version),
520 ..self
521 }
522 }
523
524 pub fn user_tree(self, tree: Option<PathBuf>) -> Self {
526 Self {
527 user_tree: tree.or(self.user_tree),
528 ..self
529 }
530 }
531
532 pub fn workspace_tree(self, tree: Option<PathBuf>) -> Self {
535 Self {
536 workspace_tree: tree.or(self.workspace_tree),
537 ..self
538 }
539 }
540
541 pub fn variables(self, variables: Option<HashMap<String, String>>) -> Self {
545 Self {
546 variables: variables.or(self.variables),
547 ..self
548 }
549 }
550
551 pub fn verbose(self, verbose: Option<bool>) -> Self {
554 Self {
555 verbose: verbose.or(self.verbose),
556 ..self
557 }
558 }
559
560 pub fn no_progress(self, no_progress: Option<bool>) -> Self {
563 Self {
564 no_progress: no_progress.or(self.no_progress),
565 ..self
566 }
567 }
568
569 pub fn no_prompt(self, no_prompt: Option<bool>) -> Self {
572 Self {
573 no_prompt: no_prompt.or(self.no_prompt),
574 ..self
575 }
576 }
577
578 pub fn timeout(self, timeout: Option<Duration>) -> Self {
582 Self {
583 timeout: timeout.or(self.timeout),
584 ..self
585 }
586 }
587
588 pub fn max_jobs(self, max_jobs: Option<usize>) -> Self {
592 Self {
593 max_jobs: max_jobs.or(self.max_jobs),
594 ..self
595 }
596 }
597
598 pub fn cache_dir(self, cache_dir: Option<PathBuf>) -> Self {
600 Self {
601 cache_dir: cache_dir.or(self.cache_dir),
602 ..self
603 }
604 }
605
606 pub fn data_dir(self, data_dir: Option<PathBuf>) -> Self {
608 Self {
609 data_dir: data_dir.or(self.data_dir),
610 ..self
611 }
612 }
613
614 pub fn vendor_dir(self, vendor_dir: Option<PathBuf>) -> Self {
618 Self {
619 vendor_dir: vendor_dir.or(self.vendor_dir),
620 ..self
621 }
622 }
623
624 pub fn entrypoint_layout(self, layout: impl CustomRockLayout + 'static) -> Self {
626 Self {
627 entrypoint_layout: Some(Arc::new(layout)),
628 ..self
629 }
630 }
631
632 pub fn user_agent(self, user_agent: Option<String>) -> Self {
635 Self {
636 user_agent: user_agent.or(self.user_agent),
637 ..self
638 }
639 }
640
641 pub fn generate_luarc(self, generate: Option<bool>) -> Self {
644 Self {
645 generate_luarc: generate.or(self.generate_luarc),
646 ..self
647 }
648 }
649
650 pub fn luarc_file_name(self, file: Option<String>) -> Self {
653 Self {
654 luarc_file_name: file.or(self.luarc_file_name),
655 ..self
656 }
657 }
658
659 pub fn wrap_bin_scripts(self, generate: Option<bool>) -> Self {
665 Self {
666 wrap_bin_scripts: generate.or(self.generate_luarc),
667 ..self
668 }
669 }
670 pub fn no_tfa(self, tfa: Option<bool>) -> Self {
673 Self {
674 no_tfa: tfa.or(self.no_tfa),
675 ..self
676 }
677 }
678
679 pub fn build_runner(self, runner: Option<Vec<String>>) -> Self {
729 Self {
730 build: BuildConfig {
731 runner: runner.unwrap_or(self.build.runner),
732 ..self.build
733 },
734 ..self
735 }
736 }
737
738 pub fn build_profile(self, profile: Option<build::Profile>) -> Self {
741 Self {
742 build: BuildConfig {
743 profile: profile.or(self.build.profile),
744 ..self.build
745 },
746 ..self
747 }
748 }
749
750 pub fn default_build_profile(self, profile: build::Profile) -> Self {
752 Self {
753 build: BuildConfig {
754 profile: self.build.profile.or(Some(profile)),
755 ..self.build
756 },
757 ..self
758 }
759 }
760
761 pub fn merge(self, other: Self) -> Self {
763 Self {
764 server: other.server.or(self.server),
765 extra_servers: other.extra_servers.or(self.extra_servers),
766 namespace: other.namespace.or(self.namespace),
767 lua_version: other.lua_version.or(self.lua_version),
768 user_tree: other.user_tree.or(self.user_tree),
769 workspace_tree: other.workspace_tree.or(self.workspace_tree),
770 lua_dir: other.lua_dir.or(self.lua_dir),
771 cache_dir: other.cache_dir.or(self.cache_dir),
772 data_dir: other.data_dir.or(self.data_dir),
773 vendor_dir: other.vendor_dir.or(self.vendor_dir),
774 enable_development_packages: other
775 .enable_development_packages
776 .or(self.enable_development_packages),
777 verbose: other.verbose.or(self.verbose),
778 no_progress: other.no_progress.or(self.no_progress),
779 no_prompt: other.no_prompt.or(self.no_prompt),
780 timeout: other.timeout.or(self.timeout),
781 max_jobs: other.max_jobs.or(self.max_jobs),
782 variables: other.variables.or(self.variables),
783 access_tokens: other.access_tokens.or(self.access_tokens),
784 external_deps: other.external_deps,
785 build: BuildConfig {
786 profile: other.build.profile.or(self.build.profile),
787 ..other.build
788 },
789 entrypoint_layout: other.entrypoint_layout.or(self.entrypoint_layout),
790 user_agent: other.user_agent.or(self.user_agent),
791 generate_luarc: other.generate_luarc.or(self.generate_luarc),
792 luarc_file_name: other.luarc_file_name.or(self.luarc_file_name),
793 wrap_bin_scripts: other.wrap_bin_scripts.or(self.wrap_bin_scripts),
794 package_types: other.package_types.or(self.package_types),
795 no_tfa: other.no_tfa.or(self.no_tfa),
796 }
797 }
798
799 #[tracing::instrument(level = "trace")]
800 pub fn build(self) -> Result<Config, ConfigError> {
801 let data_dir = self.data_dir.unwrap_or(Config::default_data_path());
802 let cache_dir = self.cache_dir.unwrap_or(Config::default_cache_path());
803 let user_tree = self.user_tree.unwrap_or(data_dir.join("tree"));
804
805 let lua_version = self
806 .lua_version
807 .or(crate::lua_installation::detect_installed_lua_version());
808
809 Ok(Config {
810 enable_development_packages: self.enable_development_packages.unwrap_or(false),
811 server: self.server.unwrap_or_else(|| unsafe {
812 Url::parse("https://luarocks.org/").unwrap_unchecked()
813 }),
814 extra_servers: self.extra_servers.unwrap_or_default(),
815 namespace: self.namespace,
816 lua_dir: self.lua_dir,
817 lua_version,
818 user_tree,
819 workspace_tree: self.workspace_tree,
820 verbose: self.verbose.unwrap_or(false),
821 no_progress: self.no_progress.unwrap_or(false),
822 no_prompt: self.no_prompt.unwrap_or(false),
823 timeout: self.timeout.unwrap_or_else(|| Duration::from_secs(30)),
824 max_jobs: match self.max_jobs.unwrap_or(usize::MAX) {
825 0 => usize::MAX,
826 max_jobs => max_jobs,
827 },
828 variables: default_variables()
829 .chain(self.variables.unwrap_or_default())
830 .collect(),
831 access_tokens: self.access_tokens.unwrap_or_default(),
832 external_deps: self.external_deps,
833 build: self.build,
834 entrypoint_layout: self.entrypoint_layout,
835 cache_dir,
836 data_dir,
837 vendor_dir: self.vendor_dir,
838 user_agent: self.user_agent.unwrap_or(DEFAULT_USER_AGENT.into()),
839 generate_luarc: self.generate_luarc.unwrap_or(true),
840 luarc_file_name: self
841 .luarc_file_name
842 .unwrap_or_else(|| ".luarc.json".to_string()),
843 wrap_bin_scripts: self.wrap_bin_scripts.unwrap_or(true),
844 package_types: self.package_types.unwrap_or_default(),
845 no_tfa: self.no_tfa.unwrap_or(false),
846 })
847 }
848}
849
850impl From<Config> for ConfigBuilder {
852 fn from(value: Config) -> Self {
853 ConfigBuilder {
854 enable_development_packages: Some(value.enable_development_packages),
855 server: Some(value.server),
856 extra_servers: Some(value.extra_servers),
857 namespace: value.namespace,
858 lua_dir: value.lua_dir,
859 lua_version: value.lua_version,
860 user_tree: Some(value.user_tree),
861 workspace_tree: value.workspace_tree,
862 verbose: Some(value.verbose),
863 no_progress: Some(value.no_progress),
864 no_prompt: Some(value.no_prompt),
865 timeout: Some(value.timeout),
866 max_jobs: if value.max_jobs == usize::MAX {
867 None
868 } else {
869 Some(value.max_jobs)
870 },
871 variables: Some(value.variables),
872 access_tokens: Some(value.access_tokens),
873 cache_dir: Some(value.cache_dir),
874 data_dir: Some(value.data_dir),
875 vendor_dir: value.vendor_dir,
876 external_deps: value.external_deps,
877 build: value.build,
878 entrypoint_layout: value.entrypoint_layout,
879 user_agent: Some(value.user_agent),
880 generate_luarc: Some(value.generate_luarc),
881 luarc_file_name: Some(value.luarc_file_name),
882 wrap_bin_scripts: Some(value.wrap_bin_scripts),
883 package_types: Some(value.package_types),
884 no_tfa: Some(value.no_tfa),
885 }
886 }
887}
888
889fn default_variables() -> impl Iterator<Item = (String, String)> {
890 let cflags = env::var("CFLAGS").unwrap_or(utils::default_cflags().into());
891 let ldflags = env::var("LDFLAGS").unwrap_or("".into());
892 vec![
893 ("MAKE".into(), "make".into()),
894 ("CMAKE".into(), "cmake".into()),
895 ("LIB_EXTENSION".into(), utils::c_dylib_extension().into()),
896 ("OBJ_EXTENSION".into(), utils::c_obj_extension().into()),
897 ("CFLAGS".into(), cflags),
898 ("LDFLAGS".into(), ldflags),
899 ("LIBFLAG".into(), utils::default_libflag().into()),
900 ]
901 .into_iter()
902}
903
904fn deserialize_url<'de, D>(deserializer: D) -> Result<Option<Url>, D::Error>
905where
906 D: serde::Deserializer<'de>,
907{
908 let s = Option::<String>::deserialize(deserializer)?;
909 s.map(|s| Url::parse(&s).map_err(serde::de::Error::custom))
910 .transpose()
911}
912
913fn serialize_url<S>(url: &Option<Url>, serializer: S) -> Result<S::Ok, S::Error>
914where
915 S: Serializer,
916{
917 match url {
918 Some(url) => serializer.serialize_some(url.as_str()),
919 None => serializer.serialize_none(),
920 }
921}
922
923fn deserialize_url_vec<'de, D>(deserializer: D) -> Result<Option<Vec<Url>>, D::Error>
924where
925 D: serde::Deserializer<'de>,
926{
927 let s = Option::<Vec<String>>::deserialize(deserializer)?;
928 s.map(|v| {
929 v.into_iter()
930 .map(|s| Url::parse(&s).map_err(serde::de::Error::custom))
931 .try_collect()
932 })
933 .transpose()
934}
935
936fn serialize_url_vec<S>(urls: &Option<Vec<Url>>, serializer: S) -> Result<S::Ok, S::Error>
937where
938 S: Serializer,
939{
940 match urls {
941 Some(urls) => {
942 let url_strings: Vec<String> = urls.iter().map(|url| url.to_string()).collect();
943 serializer.serialize_some(&url_strings)
944 }
945 None => serializer.serialize_none(),
946 }
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952
953 #[tokio::test]
954 async fn wrapped_command_without_runner() {
955 let echo = which::which("echo").unwrap();
956 let config = ConfigBuilder::default().build().unwrap();
957 let output = config
958 .wrapped_command(&echo, ["hello", "world"])
959 .output()
960 .await
961 .unwrap();
962 assert!(output.status.success());
963 assert_eq!(
964 String::from_utf8_lossy(&output.stdout).trim(),
965 "hello world"
966 );
967 }
968
969 #[tokio::test]
970 async fn wrapped_command_prepends_runner_argv() {
971 let echo = which::which("echo").unwrap();
972 let config = ConfigBuilder::default()
973 .build_runner(Some(vec![echo.to_string_lossy().into(), "--prefix".into()]))
974 .build()
975 .unwrap();
976 let output = config
977 .wrapped_command("world", Vec::<String>::new())
978 .output()
979 .await
980 .unwrap();
981 assert!(output.status.success());
982 assert_eq!(
983 String::from_utf8_lossy(&output.stdout).trim(),
984 "--prefix world"
985 );
986 }
987
988 #[test]
989 fn debug_redacts_access_tokens() {
990 let config: Config =
991 toml::from_str::<ConfigBuilder>("[access_tokens]\n\"github.com\" = \"supersecret\"\n")
992 .unwrap()
993 .build()
994 .unwrap();
995 let debug = format!("{config:?}");
996 assert!(!debug.contains("supersecret"));
997 assert!(debug.contains("access_tokens"));
998 }
999}