1use std::{
2 collections::{BTreeMap, BTreeSet, HashMap},
3 fmt,
4 path::{Path, PathBuf},
5 time::Duration,
6};
7
8use etcetera::{AppStrategy, AppStrategyArgs, choose_app_strategy};
9use figment::{
10 Figment,
11 providers::{Format, Serialized, Toml},
12 value::magic::RelativePathBuf,
13};
14use serde::{
15 Deserialize, Serialize,
16 de::{self, MapAccess, Visitor, value::MapAccessDeserializer},
17};
18use snafu::ResultExt;
19use strum::{Display, EnumIter, EnumString, IntoStaticStr, VariantNames};
20
21use crate::Result;
22
23const DEFAULT_RESOLVE_CACHE_TIMEOUT: Duration = Duration::from_secs(60 * 60);
24const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
25const DEFAULT_HTTP_RETRIES: usize = 2;
26const DEFAULT_HTTP_BACKOFF_BASE: Duration = Duration::from_millis(500);
27const DEFAULT_HTTP_BACKOFF_MAX: Duration = Duration::from_secs(5);
28
29#[derive(
31 Default, Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, EnumString, Display, VariantNames,
32)]
33#[strum(serialize_all = "kebab-case")]
34#[serde(rename_all = "kebab-case")]
35pub enum UsePrebuiltBinaries {
36 #[default]
39 Auto,
40 Always,
43 Never,
45}
46
47#[derive(
49 Debug,
50 Clone,
51 Copy,
52 PartialEq,
53 Eq,
54 Hash,
55 Serialize,
56 Deserialize,
57 EnumString,
58 Display,
59 IntoStaticStr,
60 EnumIter,
61 VariantNames,
62)]
63#[strum(serialize_all = "kebab-case")]
64#[serde(rename_all = "kebab-case")]
65pub enum BinaryProvider {
66 Binstall,
69 GithubReleases,
71 GitlabReleases,
73 Quickinstall,
75}
76
77#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
79#[serde(default, deny_unknown_fields)]
80pub struct PrebuiltBinariesConfig {
81 pub use_prebuilt_binaries: UsePrebuiltBinaries,
83
84 pub binary_providers: Vec<BinaryProvider>,
90
91 pub verify_checksums: bool,
96
97 pub verify_signatures: bool,
104}
105
106impl Default for PrebuiltBinariesConfig {
107 fn default() -> Self {
108 Self {
109 use_prebuilt_binaries: UsePrebuiltBinaries::Auto,
110 binary_providers: vec![
111 BinaryProvider::Binstall,
112 BinaryProvider::GithubReleases,
113 BinaryProvider::GitlabReleases,
114 BinaryProvider::Quickinstall,
115 ],
116 verify_checksums: true,
117 verify_signatures: true,
118 }
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
129#[serde(default, deny_unknown_fields)]
130pub struct HttpConfig {
131 #[serde(with = "humantime_serde")]
137 pub timeout: Duration,
138
139 pub retries: usize,
141
142 #[serde(with = "humantime_serde")]
144 pub backoff_base: Duration,
145
146 #[serde(with = "humantime_serde")]
148 pub backoff_max: Duration,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub proxy: Option<String>,
153}
154
155impl Default for HttpConfig {
156 fn default() -> Self {
157 Self {
158 timeout: DEFAULT_HTTP_TIMEOUT,
159 retries: DEFAULT_HTTP_RETRIES,
160 backoff_base: DEFAULT_HTTP_BACKOFF_BASE,
161 backoff_max: DEFAULT_HTTP_BACKOFF_MAX,
162 proxy: None,
163 }
164 }
165}
166
167#[derive(Debug, Clone, Default, Deserialize, Serialize)]
169#[serde(default, deny_unknown_fields)]
170pub struct HttpConfigFile {
171 #[serde(default, with = "humantime_serde::option")]
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub timeout: Option<Duration>,
174
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub retries: Option<usize>,
177
178 #[serde(default, with = "humantime_serde::option")]
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub backoff_base: Option<Duration>,
181
182 #[serde(default, with = "humantime_serde::option")]
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub backoff_max: Option<Duration>,
185
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub proxy: Option<String>,
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize)]
201#[serde(untagged)]
202pub enum ToolConfig {
203 Version(String),
205 Detailed(ToolConfigDetailed),
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct ToolConfigDetailed {
213 #[serde(skip_serializing_if = "Option::is_none")]
214 pub version: Option<String>,
215 #[serde(skip_serializing_if = "Option::is_none")]
216 pub features: Option<Vec<String>>,
217 #[serde(
220 rename = "default-features",
221 default = "default_true",
222 skip_serializing_if = "is_true"
223 )]
224 pub default_features: bool,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub registry: Option<String>,
227 #[serde(skip_serializing_if = "Option::is_none")]
228 pub git: Option<String>,
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub branch: Option<String>,
231 #[serde(skip_serializing_if = "Option::is_none")]
232 pub tag: Option<String>,
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub rev: Option<String>,
235 #[serde(skip_serializing_if = "Option::is_none")]
236 pub path: Option<PathBuf>,
237}
238
239impl<'de> Deserialize<'de> for ToolConfig {
240 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
241 where
242 D: de::Deserializer<'de>,
243 {
244 struct ToolConfigVisitor;
245
246 impl<'de> Visitor<'de> for ToolConfigVisitor {
247 type Value = ToolConfig;
248
249 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
250 formatter.write_str("a version string or a detailed tool table")
251 }
252
253 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
254 where
255 E: de::Error,
256 {
257 Ok(ToolConfig::Version(value.to_string()))
258 }
259
260 fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
261 where
262 M: MapAccess<'de>,
263 {
264 Ok(ToolConfig::Detailed(ToolConfigDetailed::deserialize(
265 MapAccessDeserializer::new(map),
266 )?))
267 }
268 }
269
270 deserializer.deserialize_any(ToolConfigVisitor)
271 }
272}
273
274impl ToolConfig {
275 pub fn features(&self) -> Option<&[String]> {
277 match self {
278 ToolConfig::Version(_) => None,
279 ToolConfig::Detailed(ToolConfigDetailed { features, .. }) => features.as_deref(),
280 }
281 }
282
283 pub fn default_features(&self) -> bool {
288 match self {
289 ToolConfig::Version(_) => true,
290 ToolConfig::Detailed(ToolConfigDetailed { default_features, .. }) => *default_features,
291 }
292 }
293}
294
295fn is_true(value: &bool) -> bool {
301 *value
302}
303
304fn default_true() -> bool {
310 true
311}
312
313#[derive(Debug, Clone, Default, Deserialize, Serialize)]
318#[serde(default, deny_unknown_fields)]
319pub struct ConfigFile {
320 #[serde(skip_serializing_if = "Option::is_none")]
321 #[serde(deserialize_with = "deserialize_optional_expanded_path")]
322 pub bin_dir: Option<PathBuf>,
323
324 #[serde(skip_serializing_if = "Option::is_none")]
325 #[serde(deserialize_with = "deserialize_optional_expanded_path")]
326 pub build_dir: Option<PathBuf>,
327
328 #[serde(skip_serializing_if = "Option::is_none")]
329 #[serde(deserialize_with = "deserialize_optional_expanded_path")]
330 pub cache_dir: Option<PathBuf>,
331
332 #[serde(skip_serializing_if = "Option::is_none")]
333 pub locked: Option<bool>,
334
335 #[serde(skip_serializing_if = "Option::is_none")]
336 pub log_level: Option<String>,
337
338 #[serde(skip_serializing_if = "Option::is_none")]
339 pub offline: Option<bool>,
340
341 #[serde(skip_serializing_if = "Option::is_none")]
342 #[serde(with = "humantime_serde")]
343 pub resolve_cache_timeout: Option<Duration>,
344
345 #[serde(skip_serializing_if = "Option::is_none")]
346 pub toolchain: Option<String>,
347
348 #[serde(skip_serializing_if = "Option::is_none")]
349 pub default_registry: Option<String>,
350
351 #[serde(skip_serializing_if = "Option::is_none")]
352 pub prebuilt_binaries: Option<PrebuiltBinariesConfig>,
353
354 #[serde(skip_serializing_if = "Option::is_none")]
355 pub http: Option<HttpConfigFile>,
356
357 #[serde(skip_serializing_if = "Option::is_none")]
358 pub tools: Option<HashMap<String, ToolConfig>>,
359
360 #[serde(skip_serializing_if = "Option::is_none")]
361 pub aliases: Option<HashMap<String, String>>,
362}
363
364impl ConfigFile {
365 pub fn base_config() -> Self {
373 Self {
374 bin_dir: None,
375 build_dir: None,
376 cache_dir: None,
377 locked: Some(true),
378 log_level: None,
379 offline: Some(false),
380 resolve_cache_timeout: Some(DEFAULT_RESOLVE_CACHE_TIMEOUT),
381 toolchain: None,
382 default_registry: None,
383 prebuilt_binaries: Some(PrebuiltBinariesConfig::default()),
384 http: None,
385 tools: None,
386 aliases: None,
387 }
388 }
389}
390
391fn deserialize_optional_expanded_path<'de, D>(
393 deserializer: D,
394) -> std::result::Result<Option<PathBuf>, D::Error>
395where
396 D: serde::Deserializer<'de>,
397{
398 let opt_string: Option<String> = Option::deserialize(deserializer)?;
399 match opt_string {
400 None => Ok(None),
401 Some(s) => {
402 let expanded = shellexpand::tilde(&s);
403 Ok(Some(PathBuf::from(expanded.as_ref())))
404 }
405 }
406}
407
408fn tool_path_patch(config_file: &Path) -> Result<Option<ConfigFile>> {
424 #[derive(Default, Deserialize)]
425 #[serde(default)]
426 struct ToolPathPatchFile {
427 tools: HashMap<String, ToolPathPatchTool>,
428 }
429
430 enum ToolPathPatchTool {
431 Detailed(ToolPathPatchDetailed),
432 Version,
433 }
434
435 impl<'de> Deserialize<'de> for ToolPathPatchTool {
436 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
437 where
438 D: de::Deserializer<'de>,
439 {
440 struct ToolPathPatchToolVisitor;
441
442 impl<'de> Visitor<'de> for ToolPathPatchToolVisitor {
443 type Value = ToolPathPatchTool;
444
445 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
446 formatter.write_str("a version string or a detailed tool table")
447 }
448
449 fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E>
450 where
451 E: de::Error,
452 {
453 Ok(ToolPathPatchTool::Version)
454 }
455
456 fn visit_string<E>(self, _value: String) -> std::result::Result<Self::Value, E>
457 where
458 E: de::Error,
459 {
460 Ok(ToolPathPatchTool::Version)
461 }
462
463 fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
464 where
465 M: MapAccess<'de>,
466 {
467 Ok(ToolPathPatchTool::Detailed(ToolPathPatchDetailed::deserialize(
468 MapAccessDeserializer::new(map),
469 )?))
470 }
471 }
472
473 deserializer.deserialize_any(ToolPathPatchToolVisitor)
474 }
475 }
476
477 #[derive(Default, Deserialize)]
478 #[serde(default)]
479 struct ToolPathPatchDetailed {
480 path: Option<RelativePathBuf>,
481 }
482
483 let patch_file: ToolPathPatchFile = Figment::from(Toml::file(config_file))
487 .extract()
488 .context(crate::error::ConfigExtractSnafu)?;
489
490 let tools = patch_file
491 .tools
492 .into_iter()
493 .filter_map(|(name, tool)| match tool {
494 ToolPathPatchTool::Detailed(ToolPathPatchDetailed { path: Some(path) }) => Some((
495 name,
496 ToolConfig::Detailed(ToolConfigDetailed {
497 version: None,
498 features: None,
499 default_features: true,
500 registry: None,
501 git: None,
502 branch: None,
503 tag: None,
504 rev: None,
505 path: Some(path.relative()),
506 }),
507 )),
508 ToolPathPatchTool::Detailed(ToolPathPatchDetailed { path: None })
509 | ToolPathPatchTool::Version => None,
510 })
511 .collect::<HashMap<_, _>>();
512
513 if tools.is_empty() {
514 Ok(None)
515 } else {
516 Ok(Some(ConfigFile {
517 tools: Some(tools),
518 ..ConfigFile::default()
519 }))
520 }
521}
522
523#[derive(Debug, Clone)]
533pub struct Config {
534 pub config_dir: PathBuf,
536
537 pub cache_dir: PathBuf,
539
540 pub bin_dir: PathBuf,
542
543 pub build_dir: PathBuf,
548
549 pub resolve_cache_timeout: Duration,
551
552 pub offline: bool,
553
554 pub locked: bool,
555
556 pub refresh: bool,
557
558 pub toolchain: Option<String>,
560
561 pub log_level: Option<String>,
563
564 pub verbosity: Verbosity,
568
569 pub default_registry: Option<String>,
571
572 pub prebuilt_binaries: PrebuiltBinariesConfig,
574
575 pub http: HttpConfig,
577
578 pub tools: HashMap<String, ToolConfig>,
583
584 pub aliases: HashMap<String, String>,
589}
590
591impl Default for Config {
592 fn default() -> Self {
593 Self {
594 config_dir: PathBuf::default(),
595 cache_dir: PathBuf::default(),
596 bin_dir: PathBuf::default(),
597 build_dir: PathBuf::default(),
598 resolve_cache_timeout: Duration::from_secs(3600),
599 offline: false,
600 locked: true,
601 refresh: false,
602 toolchain: None,
603 log_level: None,
604 verbosity: Verbosity::default(),
605 default_registry: None,
606 prebuilt_binaries: PrebuiltBinariesConfig::default(),
607 http: HttpConfig::default(),
608 tools: HashMap::default(),
609 aliases: HashMap::default(),
610 }
611 }
612}
613
614#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
617pub enum LockMode {
618 #[default]
620 Default,
621 Locked,
623 Frozen,
625 Unlocked,
627}
628
629#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
634pub enum Verbosity {
635 #[default]
637 Normal,
638 Verbose,
640 VeryVerbose,
642 ExtremelyVerbose,
644}
645
646impl Verbosity {
647 pub fn from_count(count: u8) -> Self {
649 match count {
650 0 => Self::Normal,
651 1 => Self::Verbose,
652 2 => Self::VeryVerbose,
653 _ => Self::ExtremelyVerbose,
654 }
655 }
656}
657
658#[derive(Clone, Debug, Default)]
664pub struct ConfigOverrides {
665 pub config_file: Option<PathBuf>,
668
669 pub system_config_dir: Option<PathBuf>,
671
672 pub app_dir: Option<PathBuf>,
674
675 pub user_config_dir: Option<PathBuf>,
677
678 pub http_timeout: Option<String>,
681
682 pub http_retries: Option<usize>,
684
685 pub http_proxy: Option<String>,
687
688 pub lockfile: LockMode,
690
691 pub offline: bool,
693
694 pub refresh: bool,
696
697 pub prebuilt_binary: Option<UsePrebuiltBinaries>,
699
700 pub prebuilt_binary_sources: Option<Vec<BinaryProvider>>,
702
703 pub prebuilt_binary_no_verify_checksums: bool,
706
707 pub prebuilt_binary_no_verify_signatures: bool,
710
711 pub verbosity: Verbosity,
713}
714
715#[derive(Debug, PartialEq, Eq)]
718pub(crate) struct ConfiguredTool {
719 pub(crate) name: String,
721 pub(crate) aliases: Vec<String>,
724}
725
726impl Config {
727 pub fn load(overrides: &ConfigOverrides) -> Result<Self> {
737 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
738
739 Self::load_from_dir(&cwd, overrides)
740 }
741
742 pub fn tools_toml(&self) -> Result<String> {
748 #[derive(Serialize)]
749 struct ToolsSection<'a> {
750 tools: BTreeMap<&'a String, &'a ToolConfig>,
751 }
752 #[derive(Serialize)]
753 struct AliasesSection<'a> {
754 aliases: BTreeMap<&'a String, &'a String>,
755 }
756
757 fn ensure_section_header(section: &str, body: String) -> String {
765 let header = format!("[{section}]");
766 if body.starts_with(&header) {
767 body
768 } else {
769 format!("{header}\n{body}")
770 }
771 }
772
773 let tools = if self.tools.is_empty() {
774 "[tools]\n".to_string()
775 } else {
776 let body = toml::to_string_pretty(&ToolsSection {
777 tools: self.sorted_tools(),
778 })
779 .context(crate::error::TomlSerializeSnafu)?;
780 ensure_section_header("tools", body)
781 };
782
783 let aliases = if self.aliases.is_empty() {
784 "[aliases]\n".to_string()
785 } else {
786 let body = toml::to_string_pretty(&AliasesSection {
787 aliases: self.sorted_aliases(),
788 })
789 .context(crate::error::TomlSerializeSnafu)?;
790 ensure_section_header("aliases", body)
791 };
792
793 Ok(format!("{tools}\n{aliases}"))
794 }
795
796 pub(crate) fn sorted_tools(&self) -> BTreeMap<&String, &ToolConfig> {
801 self.tools.iter().collect()
802 }
803
804 pub(crate) fn sorted_aliases(&self) -> BTreeMap<&String, &String> {
806 self.aliases.iter().collect()
807 }
808
809 pub(crate) fn configured_tools(&self) -> Vec<ConfiguredTool> {
815 let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
816 for name in self.tools.keys().chain(self.aliases.keys()) {
817 let resolved = self.aliases.get(name).unwrap_or(name);
818 groups.entry(resolved.clone()).or_default().insert(name.clone());
819 }
820
821 groups
822 .into_iter()
823 .map(|(name, members)| {
824 let aliases = members.into_iter().filter(|member| *member != name).collect();
825 ConfiguredTool { name, aliases }
826 })
827 .collect()
828 }
829
830 pub fn load_from_dir(cwd: &Path, overrides: &ConfigOverrides) -> Result<Self> {
833 let strategy = Self::get_user_dirs()?;
834
835 let mut figment = Figment::new().merge(Serialized::defaults(ConfigFile::base_config()));
837
838 for config_file in Self::discover_config_files(cwd, overrides)? {
839 figment = figment.merge(Toml::file(&config_file));
840 if let Some(path_patch) = tool_path_patch(&config_file)? {
844 figment = figment.merge(Serialized::defaults(path_patch));
845 }
846 }
847
848 let config_file: ConfigFile = figment.extract().context(crate::error::ConfigExtractSnafu)?;
850
851 let locked = match overrides.lockfile {
855 LockMode::Unlocked => false,
856 LockMode::Locked | LockMode::Frozen => true,
857 LockMode::Default => config_file.locked.unwrap_or(true),
858 };
859
860 let offline = if overrides.offline || overrides.lockfile == LockMode::Frozen {
862 true
863 } else {
864 config_file.offline.unwrap_or(false)
865 };
866
867 let toolchain = config_file.toolchain;
871
872 let config_dir = if let Some(user_config_dir) = &overrides.user_config_dir {
874 user_config_dir.clone()
875 } else if let Some(app_dir) = &overrides.app_dir {
876 app_dir.join("config")
877 } else {
878 strategy.config_dir()
879 };
880
881 let cache_dir = if let Some(app_dir) = &overrides.app_dir {
883 app_dir.join("cache")
884 } else {
885 config_file.cache_dir.unwrap_or_else(|| strategy.cache_dir())
886 };
887
888 let bin_dir = if let Some(app_dir) = &overrides.app_dir {
890 app_dir.join("bins")
891 } else {
892 config_file
893 .bin_dir
894 .unwrap_or_else(|| strategy.in_data_dir("bins"))
895 };
896
897 let build_dir = if let Some(app_dir) = &overrides.app_dir {
899 app_dir.join("build")
900 } else {
901 config_file
902 .build_dir
903 .unwrap_or_else(|| strategy.in_data_dir("build"))
904 };
905
906 let mut prebuilt_binaries = config_file.prebuilt_binaries.unwrap_or_default();
907
908 if let Some(mode) = overrides.prebuilt_binary {
910 prebuilt_binaries.use_prebuilt_binaries = mode;
911 }
912 if let Some(ref providers) = overrides.prebuilt_binary_sources {
913 prebuilt_binaries.binary_providers = providers.clone();
914 }
915 if overrides.prebuilt_binary_no_verify_checksums {
916 prebuilt_binaries.verify_checksums = false;
917 }
918 if overrides.prebuilt_binary_no_verify_signatures {
919 prebuilt_binaries.verify_signatures = false;
920 }
921
922 if prebuilt_binaries.binary_providers.is_empty()
924 && prebuilt_binaries.use_prebuilt_binaries != UsePrebuiltBinaries::Never
925 {
926 return crate::error::NoProvidersConfiguredSnafu.fail();
927 }
928
929 let http_config_file = config_file.http.unwrap_or_default();
931 let http = Self::build_http_config(&http_config_file, overrides)?;
932
933 Ok(Self {
934 config_dir,
935 cache_dir,
936 bin_dir,
937 build_dir,
938 resolve_cache_timeout: config_file
939 .resolve_cache_timeout
940 .unwrap_or(DEFAULT_RESOLVE_CACHE_TIMEOUT),
941 offline,
942 locked,
943 refresh: overrides.refresh,
944 toolchain,
945 log_level: config_file.log_level,
946 verbosity: overrides.verbosity,
947 default_registry: config_file.default_registry,
948 prebuilt_binaries,
949 http,
950 tools: config_file.tools.unwrap_or_default(),
951 aliases: config_file.aliases.unwrap_or_default(),
952 })
953 }
954
955 fn discover_config_files(cwd: &Path, overrides: &ConfigOverrides) -> Result<Vec<PathBuf>> {
965 let mut config_files = Vec::new();
966
967 if let Some(config_path) = &overrides.config_file {
969 return Ok(vec![config_path.clone()]);
970 }
971
972 if let Some(system_config_dir) = &overrides.system_config_dir {
974 let system_config = system_config_dir.join("cgx.toml");
975 if system_config.exists() {
976 config_files.push(system_config);
977 }
978 } else {
979 #[cfg(unix)]
980 {
981 let system_config = PathBuf::from("/etc/cgx.toml");
982 if system_config.exists() {
983 config_files.push(system_config);
984 }
985 }
986
987 #[cfg(windows)]
988 {
989 if let Some(program_data) = std::env::var_os("ProgramData") {
990 let system_config = PathBuf::from(program_data).join("cgx").join("cgx.toml");
991 if system_config.exists() {
992 config_files.push(system_config);
993 }
994 }
995 }
996 }
997
998 let user_config = if let Some(user_config_dir) = &overrides.user_config_dir {
1000 user_config_dir.join("cgx.toml")
1002 } else if let Some(app_dir) = &overrides.app_dir {
1003 app_dir.join("config").join("cgx.toml")
1005 } else {
1006 let strategy = Self::get_user_dirs()?;
1008 strategy.config_dir().join("cgx.toml")
1009 };
1010
1011 if user_config.exists() {
1012 config_files.push(user_config);
1013 }
1014
1015 let mut ancestors: Vec<PathBuf> = cwd.ancestors().map(|p| p.to_path_buf()).collect();
1016 ancestors.reverse();
1017
1018 for ancestor in ancestors {
1019 let config_file = ancestor.join("cgx.toml");
1020 if config_file.exists() {
1021 config_files.push(config_file);
1022 }
1023 }
1024
1025 Ok(config_files)
1026 }
1027
1028 fn get_user_dirs() -> Result<impl AppStrategy> {
1029 choose_app_strategy(AppStrategyArgs {
1030 top_level_domain: "org".to_string(),
1031 author: "anelson".to_string(),
1032 app_name: "cgx".to_string(),
1033 })
1034 .context(crate::error::EtceteraSnafu)
1035 }
1036
1037 fn build_http_config(config_file: &HttpConfigFile, overrides: &ConfigOverrides) -> Result<HttpConfig> {
1043 let cli_timeout = overrides.http_timeout.as_ref();
1045 let cli_retries = overrides.http_retries;
1046 let cli_proxy = overrides.http_proxy.as_ref();
1047
1048 let timeout = if let Some(timeout_str) = cli_timeout {
1050 humantime::parse_duration(timeout_str).context(crate::error::InvalidHttpTimeoutSnafu {
1051 value: timeout_str.clone(),
1052 })?
1053 } else if let Some(config_timeout) = config_file.timeout {
1054 config_timeout
1055 } else if let Ok(cargo_timeout) = std::env::var("CARGO_HTTP_TIMEOUT") {
1056 if let Ok(secs) = cargo_timeout.parse::<u64>() {
1057 Duration::from_secs(secs)
1058 } else {
1059 tracing::warn!(
1060 "Invalid CARGO_HTTP_TIMEOUT value '{}', falling back to default {:?}.",
1061 cargo_timeout,
1062 DEFAULT_HTTP_TIMEOUT
1063 );
1064 DEFAULT_HTTP_TIMEOUT
1065 }
1066 } else {
1067 DEFAULT_HTTP_TIMEOUT
1068 };
1069
1070 let retries = if let Some(cli_retries) = cli_retries {
1072 cli_retries
1073 } else if let Some(config_retries) = config_file.retries {
1074 config_retries
1075 } else if let Ok(cargo_retry) = std::env::var("CARGO_NET_RETRY") {
1076 if let Ok(retries) = cargo_retry.parse::<usize>() {
1077 retries
1078 } else {
1079 tracing::warn!(
1080 "Invalid CARGO_NET_RETRY value '{}', falling back to default {}.",
1081 cargo_retry,
1082 DEFAULT_HTTP_RETRIES
1083 );
1084 DEFAULT_HTTP_RETRIES
1085 }
1086 } else {
1087 DEFAULT_HTTP_RETRIES
1088 };
1089
1090 let proxy = if let Some(p) = cli_proxy {
1092 Some(p.clone())
1093 } else if config_file.proxy.is_some() {
1094 config_file.proxy.clone()
1095 } else if let Ok(cargo_proxy) = std::env::var("CARGO_HTTP_PROXY") {
1096 Some(cargo_proxy)
1097 } else {
1098 None
1099 };
1100
1101 let backoff_base = config_file.backoff_base.unwrap_or(DEFAULT_HTTP_BACKOFF_BASE);
1103 let backoff_max = config_file.backoff_max.unwrap_or(DEFAULT_HTTP_BACKOFF_MAX);
1104
1105 Ok(HttpConfig {
1106 timeout,
1107 retries,
1108 backoff_base,
1109 backoff_max,
1110 proxy,
1111 })
1112 }
1113}
1114
1115#[cfg(test)]
1118pub(crate) fn create_test_env() -> (tempfile::TempDir, Config) {
1119 let temp_dir = tempfile::tempdir().unwrap();
1120 let config = Config {
1121 config_dir: temp_dir.path().join("config"),
1122 cache_dir: temp_dir.path().join("cache"),
1123 bin_dir: temp_dir.path().join("bins"),
1124 build_dir: temp_dir.path().join("build"),
1125 resolve_cache_timeout: Duration::from_secs(3600),
1126 locked: true,
1127 ..Default::default()
1128 };
1129
1130 (temp_dir, config)
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135 use std::path::Path;
1136
1137 use assert_matches::assert_matches;
1138
1139 use super::*;
1140 use crate::cli::Cli;
1141
1142 fn with_isolated_global_config(mut overrides: ConfigOverrides, root: &Path) -> ConfigOverrides {
1147 overrides.system_config_dir = Some(root.join("system"));
1148 overrides.user_config_dir = Some(root.join("user"));
1149 overrides
1150 }
1151
1152 #[test]
1153 fn test_deserialize_basic_config() {
1154 let toml_content = r#"
1155 bin_dir = "/usr/local/bin"
1156 cache_dir = "/tmp/cache"
1157 offline = true
1158 locked = false
1159 "#;
1160
1161 let config: ConfigFile = toml::from_str(toml_content).unwrap();
1162 assert_eq!(config.bin_dir, Some(PathBuf::from("/usr/local/bin")));
1163 assert_eq!(config.cache_dir, Some(PathBuf::from("/tmp/cache")));
1164 assert_eq!(config.offline, Some(true));
1165 assert_eq!(config.locked, Some(false));
1166 }
1167
1168 #[test]
1169 fn test_deserialize_duration() {
1170 let toml_content = r#"
1171 resolve_cache_timeout = "2h"
1172 "#;
1173
1174 let config: ConfigFile = toml::from_str(toml_content).unwrap();
1175 assert_eq!(
1176 config.resolve_cache_timeout,
1177 Some(Duration::from_secs(2 * 60 * 60))
1178 );
1179 }
1180
1181 #[test]
1182 fn test_deserialize_tilde_expansion() {
1183 let toml_content = r#"
1184 bin_dir = "~/.local/bin"
1185 "#;
1186
1187 let config: ConfigFile = toml::from_str(toml_content).unwrap();
1188 let home = std::env::var("HOME")
1189 .or_else(|_| std::env::var("USERPROFILE"))
1190 .unwrap();
1191 let expected = PathBuf::from(home).join(".local/bin");
1192 assert_eq!(config.bin_dir, Some(expected));
1193 }
1194
1195 #[test]
1196 fn test_deserialize_binary_providers() {
1197 let toml_content = r#"
1198 [prebuilt_binaries]
1199 binary_providers = ["github-releases", "quickinstall"]
1200 "#;
1201
1202 let config: ConfigFile = toml::from_str(toml_content).unwrap();
1203 assert_eq!(
1204 config.prebuilt_binaries.unwrap().binary_providers,
1205 vec![BinaryProvider::GithubReleases, BinaryProvider::Quickinstall,]
1206 );
1207 }
1208
1209 #[test]
1210 fn test_deserialize_tools_simple() {
1211 let toml_content = r#"
1212 [tools]
1213 ripgrep = "14.0"
1214 "#;
1215
1216 let config: ConfigFile = toml::from_str(toml_content).unwrap();
1217 let tools = config.tools.unwrap();
1218 assert_eq!(
1219 tools.get("ripgrep"),
1220 Some(&ToolConfig::Version("14.0".to_string()))
1221 );
1222 }
1223
1224 #[test]
1225 fn test_deserialize_tools_detailed() {
1226 let toml_content = r#"
1227 [tools]
1228 taplo-cli = { version = "1.11.0", features = ["schema"] }
1229 "#;
1230
1231 let config: ConfigFile = toml::from_str(toml_content).unwrap();
1232 let tools = config.tools.unwrap();
1233
1234 match tools.get("taplo-cli") {
1235 Some(ToolConfig::Detailed(ToolConfigDetailed {
1236 version, features, ..
1237 })) => {
1238 assert_eq!(*version, Some("1.11.0".to_string()));
1239 assert_eq!(*features, Some(vec!["schema".to_string()]));
1240 }
1241 _ => panic!("Expected Detailed tool config"),
1242 }
1243 }
1244
1245 #[test]
1246 fn test_deserialize_tools_default_features() {
1247 let toml_content = r#"
1249 [tools]
1250 no-defaults = { version = "1.0", default-features = false }
1251 with-defaults = { version = "1.0" }
1252 "#;
1253
1254 let config: ConfigFile = toml::from_str(toml_content).unwrap();
1255 let tools = config.tools.unwrap();
1256
1257 assert!(!tools.get("no-defaults").unwrap().default_features());
1259
1260 assert!(tools.get("with-defaults").unwrap().default_features());
1262
1263 let underscore = r#"
1265 [tools]
1266 nope = { version = "1.0", default_features = false }
1267 "#;
1268 assert_matches!(toml::from_str::<ConfigFile>(underscore), Err(_));
1269 }
1270
1271 #[test]
1272 fn test_default_features_round_trips_via_tools_toml() {
1273 let mut config = Config::default();
1274 config.tools.insert(
1275 "no-defaults".to_string(),
1276 ToolConfig::Detailed(ToolConfigDetailed {
1277 default_features: false,
1278 version: Some("1.0".to_string()),
1279 features: None,
1280 registry: None,
1281 git: None,
1282 branch: None,
1283 tag: None,
1284 rev: None,
1285 path: None,
1286 }),
1287 );
1288 config.tools.insert(
1289 "with-defaults".to_string(),
1290 ToolConfig::Detailed(ToolConfigDetailed {
1291 default_features: true,
1292 version: Some("1.0".to_string()),
1293 features: None,
1294 registry: None,
1295 git: None,
1296 branch: None,
1297 tag: None,
1298 rev: None,
1299 path: None,
1300 }),
1301 );
1302
1303 let rendered = config.tools_toml().unwrap();
1304
1305 assert!(rendered.contains("default-features = false"));
1308 assert!(!rendered.contains("default-features = true"));
1309
1310 let parsed: ConfigFile = toml::from_str(&rendered).unwrap();
1312 let tools = parsed.tools.unwrap();
1313 assert!(!tools.get("no-defaults").unwrap().default_features());
1314 assert!(tools.get("with-defaults").unwrap().default_features());
1315 }
1316
1317 #[test]
1318 fn test_deserialize_aliases() {
1319 let toml_content = r#"
1320 [aliases]
1321 rg = "ripgrep"
1322 taplo = "taplo-cli"
1323 "#;
1324
1325 let config: ConfigFile = toml::from_str(toml_content).unwrap();
1326 let aliases = config.aliases.unwrap();
1327 assert_eq!(aliases.get("rg"), Some(&"ripgrep".to_string()));
1328 assert_eq!(aliases.get("taplo"), Some(&"taplo-cli".to_string()));
1329 }
1330
1331 #[test]
1332 fn test_tools_toml_is_sorted_and_valid() {
1333 let mut config = Config::default();
1334 config
1335 .tools
1336 .insert("zeta".to_string(), ToolConfig::Version("2".to_string()));
1337 config
1338 .tools
1339 .insert("alpha".to_string(), ToolConfig::Version("1".to_string()));
1340 config.tools.insert(
1341 "beta".to_string(),
1342 ToolConfig::Detailed(ToolConfigDetailed {
1343 default_features: true,
1344 version: Some("1.5".to_string()),
1345 features: Some(vec!["frobnulator".to_string()]),
1346 registry: None,
1347 git: None,
1348 branch: None,
1349 tag: None,
1350 rev: None,
1351 path: None,
1352 }),
1353 );
1354 config.aliases.insert("zz".to_string(), "zeta".to_string());
1355 config.aliases.insert("aa".to_string(), "alpha".to_string());
1356
1357 let rendered = config.tools_toml().unwrap();
1358 let parsed: ConfigFile = toml::from_str(&rendered).unwrap();
1359
1360 let tools = parsed.tools.unwrap();
1361 assert_eq!(tools.get("alpha"), Some(&ToolConfig::Version("1".to_string())));
1362 assert_eq!(tools.get("zeta"), Some(&ToolConfig::Version("2".to_string())));
1363 assert_matches!(
1364 tools.get("beta"),
1365 Some(ToolConfig::Detailed(ToolConfigDetailed {
1366 version: Some(version),
1367 features: Some(features),
1368 ..
1369 })) if version == "1.5" && features == &vec!["frobnulator".to_string()]
1370 );
1371
1372 let aliases = parsed.aliases.unwrap();
1373 assert_eq!(aliases.get("aa"), Some(&"alpha".to_string()));
1374 assert_eq!(aliases.get("zz"), Some(&"zeta".to_string()));
1375
1376 assert!(rendered.find("alpha").unwrap() < rendered.find("zeta").unwrap());
1377 assert!(rendered.find("aa").unwrap() < rendered.find("zz").unwrap());
1378 }
1379
1380 fn config_with(tools: &[&str], aliases: &[(&str, &str)]) -> Config {
1381 let mut config = Config::default();
1382 for &tool in tools {
1383 config
1384 .tools
1385 .insert(tool.to_string(), ToolConfig::Version("*".to_string()));
1386 }
1387 for &(name, target) in aliases {
1388 config.aliases.insert(name.to_string(), target.to_string());
1389 }
1390 config
1391 }
1392
1393 #[test]
1394 fn configured_tools_groups_alias_with_its_tool() {
1395 let config = config_with(&["eza"], &[("e", "eza")]);
1396 assert_eq!(
1397 config.configured_tools(),
1398 [ConfiguredTool {
1399 name: "eza".to_string(),
1400 aliases: vec!["e".to_string()],
1401 }]
1402 );
1403 }
1404
1405 #[test]
1406 fn configured_tools_alias_to_unconfigured_crate_yields_its_target() {
1407 let config = config_with(&[], &[("x", "ripgrep")]);
1408 assert_eq!(
1409 config.configured_tools(),
1410 [ConfiguredTool {
1411 name: "ripgrep".to_string(),
1412 aliases: vec!["x".to_string()],
1413 }]
1414 );
1415 }
1416
1417 #[test]
1418 fn configured_tools_groups_multiple_aliases_under_one_tool() {
1419 let config = config_with(&["eza"], &[("e", "eza"), ("ez", "eza")]);
1420 assert_eq!(
1421 config.configured_tools(),
1422 [ConfiguredTool {
1423 name: "eza".to_string(),
1424 aliases: vec!["e".to_string(), "ez".to_string()],
1425 }]
1426 );
1427 }
1428
1429 #[test]
1430 fn configured_tools_are_deterministically_ordered() {
1431 let config = config_with(&["zoxide", "eza"], &[("a", "zoxide")]);
1432 let names: Vec<_> = config
1433 .configured_tools()
1434 .into_iter()
1435 .map(|tool| tool.name)
1436 .collect();
1437 assert_eq!(names, ["eza", "zoxide"]);
1438 }
1439
1440 #[test]
1441 fn configured_tools_keep_independent_tools_separate() {
1442 let config = config_with(&["eza", "ripgrep"], &[]);
1443 assert_eq!(
1444 config.configured_tools(),
1445 [
1446 ConfiguredTool {
1447 name: "eza".to_string(),
1448 aliases: Vec::new(),
1449 },
1450 ConfiguredTool {
1451 name: "ripgrep".to_string(),
1452 aliases: Vec::new(),
1453 },
1454 ]
1455 );
1456 }
1457
1458 #[test]
1459 fn tools_toml_empty_config_still_renders_both_headers() {
1460 let rendered = Config::default().tools_toml().unwrap();
1461 assert!(rendered.contains("[tools]"), "missing [tools] in:\n{rendered}");
1462 assert!(
1463 rendered.contains("[aliases]"),
1464 "missing [aliases] in:\n{rendered}"
1465 );
1466
1467 let parsed: ConfigFile = toml::from_str(&rendered).unwrap();
1468 assert!(parsed.tools.unwrap_or_default().is_empty());
1469 assert!(parsed.aliases.unwrap_or_default().is_empty());
1470 }
1471
1472 #[test]
1473 fn tools_toml_tools_only_still_renders_aliases_header() {
1474 let config = config_with(&["ripgrep"], &[]);
1475 let rendered = config.tools_toml().unwrap();
1476 assert!(rendered.contains("[tools]"));
1477 assert!(rendered.contains("[aliases]"));
1478
1479 let parsed: ConfigFile = toml::from_str(&rendered).unwrap();
1480 assert!(parsed.tools.unwrap().contains_key("ripgrep"));
1481 }
1482
1483 #[test]
1484 fn tools_toml_aliases_only_still_renders_tools_header() {
1485 let config = config_with(&[], &[("rg", "ripgrep")]);
1486 let rendered = config.tools_toml().unwrap();
1487 assert!(rendered.contains("[tools]"));
1488 assert!(rendered.contains("[aliases]"));
1489
1490 let parsed: ConfigFile = toml::from_str(&rendered).unwrap();
1491 assert_eq!(parsed.aliases.unwrap().get("rg"), Some(&"ripgrep".to_string()));
1492 }
1493
1494 #[test]
1495 fn tools_toml_all_detailed_entries_still_render_bare_tools_header() {
1496 let mut config = Config::default();
1497 config.tools.insert(
1498 "only-detailed".to_string(),
1499 ToolConfig::Detailed(ToolConfigDetailed {
1500 version: Some("1.0".to_string()),
1501 features: Some(vec!["x".to_string()]),
1502 default_features: true,
1503 registry: None,
1504 git: None,
1505 branch: None,
1506 tag: None,
1507 rev: None,
1508 path: None,
1509 }),
1510 );
1511
1512 let rendered = config.tools_toml().unwrap();
1513
1514 assert!(
1517 rendered.lines().any(|line| line.trim() == "[tools]"),
1518 "missing bare [tools] header in:\n{rendered}"
1519 );
1520 let parsed: ConfigFile = toml::from_str(&rendered).unwrap();
1521 assert!(parsed.tools.unwrap().contains_key("only-detailed"));
1522 }
1523
1524 #[test]
1525 fn tool_config_unknown_key_error_names_the_field() {
1526 let toml_content = r#"
1527 [tools]
1528 ripgrep = { versio = "14" } # spellchecker:disable-line
1529 "#;
1530 let error = toml::from_str::<ConfigFile>(toml_content)
1531 .unwrap_err()
1532 .to_string();
1533 assert!(
1534 error.contains("versio"), "error did not name the bad key:\n{error}"
1536 );
1537 assert!(
1538 !error.contains("did not match any variant"),
1539 "error was the opaque untagged message:\n{error}"
1540 );
1541 }
1542
1543 #[test]
1544 fn tool_config_type_mismatch_error_is_precise() {
1545 let toml_content = r#"
1546 [tools]
1547 ripgrep = { default-features = "false" }
1548 "#;
1549 let error = toml::from_str::<ConfigFile>(toml_content)
1550 .unwrap_err()
1551 .to_string();
1552 assert!(
1553 error.contains("boolean"),
1554 "error did not describe the expected type:\n{error}"
1555 );
1556 assert!(
1557 !error.contains("did not match any variant"),
1558 "error was the opaque untagged message:\n{error}"
1559 );
1560 }
1561
1562 fn toml_path(path: &Path) -> String {
1563 path.display().to_string().replace('\\', "\\\\")
1564 }
1565
1566 fn patched_tool_path(patch: &ConfigFile, tool_name: &str) -> PathBuf {
1567 let tools = patch.tools.as_ref().unwrap();
1568 match tools.get(tool_name) {
1569 Some(ToolConfig::Detailed(ToolConfigDetailed { path: Some(path), .. })) => path.clone(),
1570 other => panic!("expected detailed tool path for {tool_name}, got {other:?}"),
1571 }
1572 }
1573
1574 #[test]
1575 fn tool_path_patch_resolves_relative_path_from_config_file() {
1576 let temp_dir = tempfile::tempdir().unwrap();
1577 let project_dir = temp_dir.path().join("project");
1578 std::fs::create_dir_all(&project_dir).unwrap();
1579 let config_path = project_dir.join("cgx.toml");
1580 std::fs::write(
1581 &config_path,
1582 r#"
1583 [tools]
1584 local-tool = { path = "tools/local-tool" }
1585 "#,
1586 )
1587 .unwrap();
1588
1589 let patch = tool_path_patch(&config_path).unwrap().unwrap();
1590
1591 assert_eq!(
1592 patched_tool_path(&patch, "local-tool"),
1593 project_dir.join("tools/local-tool")
1594 );
1595 }
1596
1597 #[test]
1598 fn tool_path_patch_leaves_absolute_path_unchanged() {
1599 let temp_dir = tempfile::tempdir().unwrap();
1600 let absolute_path = temp_dir.path().join("tools").join("local-tool");
1601 let config_path = temp_dir.path().join("cgx.toml");
1602 std::fs::write(
1603 &config_path,
1604 format!(
1605 r#"
1606 [tools]
1607 local-tool = {{ path = "{}" }}
1608 "#,
1609 toml_path(&absolute_path)
1610 ),
1611 )
1612 .unwrap();
1613
1614 let patch = tool_path_patch(&config_path).unwrap().unwrap();
1615
1616 assert_eq!(patched_tool_path(&patch, "local-tool"), absolute_path);
1617 }
1618
1619 #[test]
1620 fn tool_path_patch_ignores_tools_without_paths() {
1621 let temp_dir = tempfile::tempdir().unwrap();
1622 let config_path = temp_dir.path().join("cgx.toml");
1623 std::fs::write(
1624 &config_path,
1625 r#"
1626 [tools]
1627 string-tool = "1"
1628 detailed-tool = { version = "1" }
1629 "#,
1630 )
1631 .unwrap();
1632
1633 let patch = tool_path_patch(&config_path).unwrap();
1634
1635 assert!(patch.is_none());
1636 }
1637
1638 #[test]
1639 fn tool_path_patch_does_not_hide_unknown_tool_fields() {
1640 let temp_dir = tempfile::tempdir().unwrap();
1641 let config_path = temp_dir.path().join("cgx.toml");
1642 std::fs::write(
1643 &config_path,
1644 r#"
1645 [tools]
1646 local-tool = { path = "tools/local-tool", versio = "1" } # spellchecker:disable-line
1647 "#,
1648 )
1649 .unwrap();
1650
1651 let patch = tool_path_patch(&config_path).unwrap().unwrap();
1652 let figment = Figment::new()
1653 .merge(Serialized::defaults(ConfigFile::base_config()))
1654 .merge(Toml::file(&config_path))
1655 .merge(Serialized::defaults(patch));
1656
1657 assert_matches!(figment.extract::<ConfigFile>(), Err(_));
1658 }
1659
1660 #[test]
1661 fn config_hierarchy_merges_parent_path_with_child_version() {
1662 let temp_dir = tempfile::tempdir().unwrap();
1663 let parent = temp_dir.path().join("parent");
1664 let child = parent.join("child");
1665 std::fs::create_dir_all(&child).unwrap();
1666 std::fs::write(
1667 parent.join("cgx.toml"),
1668 r#"
1669 [tools]
1670 local-tool = { path = "tools/local-tool" }
1671 "#,
1672 )
1673 .unwrap();
1674 std::fs::write(
1675 child.join("cgx.toml"),
1676 r#"
1677 [tools]
1678 local-tool = { version = "1" }
1679 "#,
1680 )
1681 .unwrap();
1682
1683 let config_overrides = with_isolated_global_config(
1684 Cli::parse_from_test_args(["local-tool"]).to_config_overrides(),
1685 temp_dir.path(),
1686 );
1687 let config = Config::load_from_dir(&child, &config_overrides).unwrap();
1688
1689 assert_matches!(
1690 config.tools.get("local-tool"),
1691 Some(ToolConfig::Detailed(ToolConfigDetailed {
1692 version: Some(version),
1693 path: Some(path),
1694 ..
1695 })) if version == "1" && path == &parent.join("tools/local-tool")
1696 );
1697 }
1698
1699 #[test]
1700 fn test_config_defaults() {
1701 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
1702 let config = Config::load(&config_overrides).unwrap();
1703
1704 assert!(!config.offline);
1705 assert!(config.locked); assert_eq!(config.toolchain, None);
1707 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(60 * 60));
1708 }
1709
1710 #[test]
1711 fn test_cli_overrides() {
1712 let cli = Cli::parse_from_test_args(["+nightly", "--offline", "--locked", "test-crate"]);
1713 let config_overrides = cli.to_config_overrides();
1714 let config = Config::load(&config_overrides).unwrap();
1715 let build_overrides = if let Cli::Run { args, .. } = cli {
1716 args.to_build_overrides()
1717 } else {
1718 panic!("Expected Run command")
1719 };
1720
1721 assert!(config.offline);
1722 assert!(config.locked);
1723 assert_eq!(config.toolchain, None);
1728 assert_eq!(build_overrides.toolchain, Some("nightly".to_string()));
1729 }
1730
1731 #[test]
1732 fn test_frozen_implies_locked_and_offline() {
1733 let config_overrides = Cli::parse_from_test_args(["--frozen", "test-crate"]).to_config_overrides();
1734 let config = Config::load(&config_overrides).unwrap();
1735
1736 assert!(config.offline);
1737 assert!(config.locked);
1738 }
1739
1740 #[test]
1741 fn test_full_config_example() {
1742 let toml_content = r#"
1743 bin_dir = "~/.local/bin"
1744 build_dir = "~/.local/build"
1745 cache_dir = "~/.cache/cgx"
1746 locked = true
1747 log_level = "info"
1748 offline = false
1749 resolve_cache_timeout = "1h"
1750 toolchain = "stable"
1751 default_registry = "my-registry"
1752
1753 [prebuilt_binaries]
1754 binary_providers = ["github-releases", "gitlab-releases", "quickinstall"]
1755
1756 [tools]
1757 ripgrep = "*"
1758 taplo-cli = { version = "1.11.0", features = ["schema"] }
1759
1760 [aliases]
1761 rg = "ripgrep"
1762 taplo = "taplo-cli"
1763 "#;
1764
1765 let config: ConfigFile = toml::from_str(toml_content).unwrap();
1766
1767 assert_eq!(config.log_level, Some("info".to_string()));
1768 assert_eq!(config.toolchain, Some("stable".to_string()));
1769 assert_eq!(config.default_registry, Some("my-registry".to_string()));
1770 assert_eq!(config.locked, Some(true));
1771 assert_eq!(config.offline, Some(false));
1772 assert_eq!(config.resolve_cache_timeout, Some(Duration::from_secs(60 * 60)));
1773
1774 let prebuilt_binaries = config.prebuilt_binaries.unwrap();
1775
1776 assert_eq!(prebuilt_binaries.binary_providers.len(), 3);
1777
1778 assert_eq!(prebuilt_binaries.use_prebuilt_binaries, UsePrebuiltBinaries::Auto);
1780 assert!(prebuilt_binaries.verify_checksums);
1781 assert!(prebuilt_binaries.verify_signatures);
1782
1783 let tools = config.tools.unwrap();
1784 assert_eq!(tools.len(), 2);
1785
1786 let aliases = config.aliases.unwrap();
1787 assert_eq!(aliases.len(), 2);
1788 }
1789
1790 mod prebuilt_validation_tests {
1791 use std::io::Write;
1792
1793 use assert_matches::assert_matches;
1794
1795 use super::*;
1796
1797 fn create_temp_config(toml_content: &str) -> tempfile::TempDir {
1798 let temp_dir = tempfile::tempdir().unwrap();
1799 let config_path = temp_dir.path().join("cgx.toml");
1800 let mut file = std::fs::File::create(&config_path).unwrap();
1801 file.write_all(toml_content.as_bytes()).unwrap();
1802 temp_dir
1803 }
1804
1805 #[test]
1806 fn test_empty_providers_with_auto_fails() {
1807 let toml_content = r#"
1808 [prebuilt_binaries]
1809 use_prebuilt_binaries = "auto"
1810 binary_providers = []
1811 "#;
1812
1813 let temp_dir = create_temp_config(toml_content);
1814 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
1815 let result = Config::load_from_dir(temp_dir.path(), &config_overrides);
1816 assert_matches!(result, Err(crate::error::Error::NoProvidersConfigured));
1817 }
1818
1819 #[test]
1820 fn test_empty_providers_with_always_fails() {
1821 let toml_content = r#"
1822 [prebuilt_binaries]
1823 use_prebuilt_binaries = "always"
1824 binary_providers = []
1825 "#;
1826
1827 let temp_dir = create_temp_config(toml_content);
1828 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
1829 let result = Config::load_from_dir(temp_dir.path(), &config_overrides);
1830 assert_matches!(result, Err(crate::error::Error::NoProvidersConfigured));
1831 }
1832
1833 #[test]
1834 fn test_empty_providers_with_never_ok() {
1835 let toml_content = r#"
1836 [prebuilt_binaries]
1837 use_prebuilt_binaries = "never"
1838 binary_providers = []
1839 "#;
1840
1841 let temp_dir = create_temp_config(toml_content);
1842 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
1843 let result = Config::load_from_dir(temp_dir.path(), &config_overrides);
1844 assert!(result.is_ok(), "Empty providers with 'never' mode should succeed");
1845 }
1846 }
1847
1848 mod hierarchy_tests {
1854 use assert_matches::assert_matches;
1855
1856 use super::*;
1857 use crate::builder::BuildOptions;
1858
1859 #[test]
1866 fn test_config_hierarchy_project1() {
1867 let test_case = crate::testdata::ConfigTestCase::hierarchy_project1();
1868
1869 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
1870 let config = Config::load_from_dir(test_case.path(), &config_overrides).unwrap();
1871
1872 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(3 * 60));
1873
1874 assert!(config.tools.contains_key("ripgrep"));
1875 assert!(config.tools.contains_key("root_tool"));
1876 assert!(config.tools.contains_key("taplo-cli"));
1877 assert!(config.tools.contains_key("work_tool"));
1878 assert!(config.tools.contains_key("project1_tool"));
1879 assert_eq!(config.tools.len(), 5);
1880
1881 assert_eq!(config.aliases.get("dummytool"), Some(&"project1".to_string()));
1882 assert_eq!(config.aliases.get("rg"), Some(&"ripgrep".to_string()));
1883 assert_eq!(config.aliases.get("taplo"), Some(&"taplo-cli".to_string()));
1884 assert_eq!(config.aliases.len(), 3);
1885 }
1886
1887 #[test]
1894 fn test_config_hierarchy_project2() {
1895 let test_case = crate::testdata::ConfigTestCase::hierarchy_project2();
1896
1897 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
1898 let config = Config::load_from_dir(test_case.path(), &config_overrides).unwrap();
1899
1900 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(5 * 60));
1901
1902 assert!(config.tools.contains_key("ripgrep"));
1903 assert!(config.tools.contains_key("root_tool"));
1904 assert!(config.tools.contains_key("taplo-cli"));
1905 assert!(config.tools.contains_key("work_tool"));
1906 assert!(config.tools.contains_key("project2_tool"));
1907 assert_eq!(config.tools.len(), 5);
1908
1909 assert_eq!(config.aliases.get("dummytool"), Some(&"project2".to_string()));
1910 assert_eq!(config.aliases.get("rg"), Some(&"ripgrep".to_string()));
1911 assert_eq!(config.aliases.get("taplo"), Some(&"taplo-cli".to_string()));
1912 assert_eq!(config.aliases.len(), 3);
1913 }
1914
1915 #[test]
1921 fn test_config_hierarchy_work() {
1922 let test_case = crate::testdata::ConfigTestCase::hierarchy_work();
1923
1924 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
1925 let config = Config::load_from_dir(test_case.path(), &config_overrides).unwrap();
1926
1927 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(2 * 60));
1928
1929 assert!(config.tools.contains_key("ripgrep"));
1930 assert!(config.tools.contains_key("root_tool"));
1931 assert!(config.tools.contains_key("taplo-cli"));
1932 assert!(config.tools.contains_key("work_tool"));
1933 assert_eq!(config.tools.len(), 4);
1934
1935 assert_eq!(config.aliases.get("dummytool"), Some(&"work".to_string()));
1936 assert_eq!(config.aliases.get("rg"), Some(&"ripgrep".to_string()));
1937 assert_eq!(config.aliases.get("taplo"), Some(&"taplo-cli".to_string()));
1938 assert_eq!(config.aliases.len(), 3);
1939 }
1940
1941 #[test]
1947 fn test_config_hierarchy_root() {
1948 let test_case = crate::testdata::ConfigTestCase::hierarchy_root();
1949
1950 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
1951 let config = Config::load_from_dir(test_case.path(), &config_overrides).unwrap();
1952
1953 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(60));
1954
1955 assert!(config.tools.contains_key("ripgrep"));
1956 assert!(config.tools.contains_key("root_tool"));
1957 assert!(config.tools.contains_key("taplo-cli"));
1958 assert_eq!(config.tools.len(), 3);
1959
1960 assert_eq!(config.aliases.get("dummytool"), Some(&"root".to_string()));
1961 assert_eq!(config.aliases.get("rg"), Some(&"ripgrep".to_string()));
1962 assert_eq!(config.aliases.get("taplo"), Some(&"taplo-cli".to_string()));
1963 assert_eq!(config.aliases.len(), 3);
1964 }
1965
1966 #[test]
1973 fn test_explicit_config_file() {
1974 let test_case = crate::testdata::ConfigTestCase::explicit_non_standard_name();
1975
1976 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
1977 config_overrides.config_file = Some(test_case.path().to_path_buf());
1978
1979 let config = Config::load(&config_overrides).unwrap();
1980
1981 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(6 * 60));
1982
1983 assert!(config.tools.contains_key("project1_tool"));
1984 assert_eq!(config.tools.len(), 1);
1985
1986 assert_eq!(
1987 config.aliases.get("dummytool"),
1988 Some(&"not_called_cgx_project1".to_string())
1989 );
1990 assert_eq!(config.aliases.len(), 1);
1991 }
1992
1993 #[test]
1999 fn test_tools_detailed_config_preserved() {
2000 let test_case = crate::testdata::ConfigTestCase::hierarchy_root();
2001
2002 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2003 let config = Config::load_from_dir(test_case.path(), &config_overrides).unwrap();
2004
2005 let taplo_tool = config.tools.get("taplo-cli").unwrap();
2006 assert_matches!(
2007 taplo_tool,
2008 ToolConfig::Detailed(ToolConfigDetailed {
2009 version: Some(v),
2010 features: Some(f),
2011 ..
2012 }) if v == "1.11.0" && f == &vec!["schema".to_string()]
2013 );
2014 }
2015
2016 #[test]
2022 fn test_cli_args_override_config_files() {
2023 let test_case = crate::testdata::ConfigTestCase::hierarchy_project1();
2024
2025 let cli = Cli::parse_from_test_args(["+stable", "--offline", "--locked", "test-crate"]);
2028 let config_overrides = cli.to_config_overrides();
2029 let build_overrides = if let Cli::Run { args, .. } = cli {
2030 args.to_build_overrides()
2031 } else {
2032 panic!("Expected Run command")
2033 };
2034 let config = Config::load_from_dir(test_case.path(), &config_overrides).unwrap();
2035 let build_options = BuildOptions::load(&config, &build_overrides).unwrap();
2036
2037 assert!(config.offline);
2038 assert!(config.locked);
2039
2040 assert_eq!(config.toolchain, None);
2043 assert_eq!(build_options.toolchain, Some("stable".to_string()));
2044 }
2045
2046 #[test]
2051 fn test_config_file_reads_only_specified_file() {
2052 let hierarchy_dir = crate::testdata::ConfigTestCase::hierarchy_project1();
2055
2056 let explicit_config = crate::testdata::ConfigTestCase::explicit_non_standard_name();
2058
2059 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2061 config_overrides.config_file = Some(explicit_config.path().to_path_buf());
2062
2063 let config = Config::load_from_dir(hierarchy_dir.path(), &config_overrides).unwrap();
2064
2065 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(6 * 60));
2067
2068 assert!(config.tools.contains_key("project1_tool"));
2070 assert_eq!(config.tools.len(), 1);
2071
2072 assert_eq!(
2074 config.aliases.get("dummytool"),
2075 Some(&"not_called_cgx_project1".to_string())
2076 );
2077 assert_eq!(config.aliases.len(), 1);
2078 }
2079 }
2080
2081 mod config_file_discovery_tests {
2082 use std::fs;
2083
2084 use super::*;
2085
2086 #[test]
2091 fn test_discover_only_explicit_file() {
2092 struct UserConfigGuard {
2094 path: PathBuf,
2095 should_delete: bool,
2096 }
2097
2098 impl Drop for UserConfigGuard {
2099 fn drop(&mut self) {
2100 if self.should_delete {
2101 let _ = fs::remove_file(&self.path);
2102 }
2103 }
2104 }
2105
2106 let temp_dir = tempfile::tempdir().unwrap();
2107 let cwd = temp_dir.path();
2108
2109 let root_config = cwd.join("cgx.toml");
2111 fs::write(&root_config, "resolve_cache_timeout = \"1m\"").unwrap();
2112
2113 let sub_dir = cwd.join("subdir");
2114 fs::create_dir(&sub_dir).unwrap();
2115 let sub_config = sub_dir.join("cgx.toml");
2116 fs::write(&sub_config, "resolve_cache_timeout = \"2m\"").unwrap();
2117
2118 let explicit_config = temp_dir.path().join("explicit.toml");
2120 fs::write(&explicit_config, "resolve_cache_timeout = \"3m\"").unwrap();
2121
2122 let strategy = Config::get_user_dirs().unwrap();
2124 let user_config_dir = strategy.config_dir();
2125 let _ = fs::create_dir_all(&user_config_dir);
2126 let user_config_path = user_config_dir.join("cgx.toml");
2127 let user_config_existed = user_config_path.exists();
2128
2129 let _guard = if !user_config_existed {
2131 fs::write(&user_config_path, "resolve_cache_timeout = \"99m\"").unwrap();
2132 UserConfigGuard {
2133 path: user_config_path,
2134 should_delete: true,
2135 }
2136 } else {
2137 UserConfigGuard {
2138 path: user_config_path,
2139 should_delete: false,
2140 }
2141 };
2142
2143 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2145 config_overrides.config_file = Some(explicit_config.clone());
2146
2147 let discovered = Config::discover_config_files(&sub_dir, &config_overrides).unwrap();
2148
2149 assert_eq!(
2152 discovered.len(),
2153 1,
2154 "Expected only 1 config file, got {}: {:?}",
2155 discovered.len(),
2156 discovered
2157 );
2158 assert_eq!(discovered[0], explicit_config);
2159 }
2160
2161 #[test]
2163 fn test_discover_hierarchy_without_explicit() {
2164 let temp_dir = tempfile::tempdir().unwrap();
2165 let cwd = temp_dir.path();
2166
2167 let root_config = cwd.join("cgx.toml");
2169 fs::write(&root_config, "resolve_cache_timeout = \"1m\"").unwrap();
2170
2171 let sub_dir = cwd.join("subdir");
2172 fs::create_dir(&sub_dir).unwrap();
2173 let sub_config = sub_dir.join("cgx.toml");
2174 fs::write(&sub_config, "resolve_cache_timeout = \"2m\"").unwrap();
2175
2176 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2177 let discovered = Config::discover_config_files(&sub_dir, &config_overrides).unwrap();
2178
2179 assert!(
2182 discovered.contains(&root_config),
2183 "Root config should be discovered"
2184 );
2185 assert!(
2186 discovered.contains(&sub_config),
2187 "Sub config should be discovered"
2188 );
2189 }
2190 }
2191
2192 mod override_tests {
2193 use std::fs;
2194
2195 use super::*;
2196
2197 mod system_config_dir_tests {
2198 use super::*;
2199
2200 #[test]
2201 fn test_system_config_dir_cli_arg() {
2202 let temp_dir = tempfile::tempdir().unwrap();
2203 let system_config_dir = temp_dir.path().join("system");
2204 fs::create_dir_all(&system_config_dir).unwrap();
2205 let system_config = system_config_dir.join("cgx.toml");
2206 fs::write(&system_config, "resolve_cache_timeout = \"5m\"").unwrap();
2207
2208 let cwd = temp_dir.path().join("work");
2209 fs::create_dir_all(&cwd).unwrap();
2210
2211 let user_config_dir = temp_dir.path().join("user");
2213 fs::create_dir_all(&user_config_dir).unwrap();
2214
2215 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2216 config_overrides.system_config_dir = Some(system_config_dir);
2217 config_overrides.user_config_dir = Some(user_config_dir);
2218
2219 let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2220 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(5 * 60));
2221 }
2222
2223 #[test]
2224 fn test_system_config_dir_vs_user_config() {
2225 let temp_dir = tempfile::tempdir().unwrap();
2226
2227 let system_config_dir = temp_dir.path().join("system");
2229 fs::create_dir_all(&system_config_dir).unwrap();
2230 fs::write(
2231 system_config_dir.join("cgx.toml"),
2232 "resolve_cache_timeout = \"10m\"",
2233 )
2234 .unwrap();
2235
2236 let user_config_dir = temp_dir.path().join("user");
2238 fs::create_dir_all(&user_config_dir).unwrap();
2239 fs::write(
2240 user_config_dir.join("cgx.toml"),
2241 "resolve_cache_timeout = \"20m\"",
2242 )
2243 .unwrap();
2244
2245 let cwd = temp_dir.path().join("work");
2246 fs::create_dir_all(&cwd).unwrap();
2247
2248 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2249 config_overrides.system_config_dir = Some(system_config_dir);
2250 config_overrides.user_config_dir = Some(user_config_dir);
2251
2252 let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2253 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(20 * 60));
2255 }
2256 }
2257
2258 mod app_dir_tests {
2259 use super::*;
2260
2261 #[test]
2262 fn test_app_dir_config_location() {
2263 let temp_dir = tempfile::tempdir().unwrap();
2264 let app_dir = temp_dir.path().join("app");
2265 let config_dir = app_dir.join("config");
2266 fs::create_dir_all(&config_dir).unwrap();
2267 fs::write(config_dir.join("cgx.toml"), "resolve_cache_timeout = \"7m\"").unwrap();
2268
2269 let cwd = temp_dir.path().join("work");
2270 fs::create_dir_all(&cwd).unwrap();
2271
2272 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2273 config_overrides.app_dir = Some(app_dir.clone());
2274
2275 let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2276 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(7 * 60));
2277 assert_eq!(config.config_dir, config_dir);
2278 }
2279
2280 #[test]
2281 fn test_app_dir_cache_location() {
2282 let temp_dir = tempfile::tempdir().unwrap();
2283 let app_dir = temp_dir.path().join("app");
2284 let cwd = temp_dir.path().join("work");
2285 fs::create_dir_all(&cwd).unwrap();
2286
2287 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2288 config_overrides.app_dir = Some(app_dir.clone());
2289
2290 let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2291 assert_eq!(config.cache_dir, app_dir.join("cache"));
2292 }
2293
2294 #[test]
2295 fn test_app_dir_bins_location() {
2296 let temp_dir = tempfile::tempdir().unwrap();
2297 let app_dir = temp_dir.path().join("app");
2298 let cwd = temp_dir.path().join("work");
2299 fs::create_dir_all(&cwd).unwrap();
2300
2301 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2302 config_overrides.app_dir = Some(app_dir.clone());
2303
2304 let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2305 assert_eq!(config.bin_dir, app_dir.join("bins"));
2306 }
2307
2308 #[test]
2309 fn test_app_dir_build_location() {
2310 let temp_dir = tempfile::tempdir().unwrap();
2311 let app_dir = temp_dir.path().join("app");
2312 let cwd = temp_dir.path().join("work");
2313 fs::create_dir_all(&cwd).unwrap();
2314
2315 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2316 config_overrides.app_dir = Some(app_dir.clone());
2317
2318 let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2319 assert_eq!(config.build_dir, app_dir.join("build"));
2320 }
2321
2322 #[test]
2323 fn test_app_dir_complete_isolation() {
2324 let temp_dir = tempfile::tempdir().unwrap();
2325 let app_dir = temp_dir.path().join("app");
2326 let cwd = temp_dir.path().join("work");
2327 fs::create_dir_all(&cwd).unwrap();
2328
2329 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2330 config_overrides.app_dir = Some(app_dir.clone());
2331
2332 let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2333
2334 assert!(config.config_dir.starts_with(&app_dir));
2336 assert!(config.cache_dir.starts_with(&app_dir));
2337 assert!(config.bin_dir.starts_with(&app_dir));
2338 assert!(config.build_dir.starts_with(&app_dir));
2339 }
2340 }
2341
2342 mod user_config_dir_tests {
2343 use super::*;
2344
2345 #[test]
2346 fn test_user_config_dir_cli_arg() {
2347 let temp_dir = tempfile::tempdir().unwrap();
2348 let user_config_dir = temp_dir.path().join("user");
2349 fs::create_dir_all(&user_config_dir).unwrap();
2350 fs::write(user_config_dir.join("cgx.toml"), "resolve_cache_timeout = \"8m\"").unwrap();
2351
2352 let cwd = temp_dir.path().join("work");
2353 fs::create_dir_all(&cwd).unwrap();
2354
2355 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2356 config_overrides.user_config_dir = Some(user_config_dir.clone());
2357
2358 let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2359 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(8 * 60));
2360 assert_eq!(config.config_dir, user_config_dir);
2361 }
2362
2363 #[test]
2364 fn test_user_config_dir_overrides_app_dir() {
2365 let temp_dir = tempfile::tempdir().unwrap();
2366
2367 let app_dir = temp_dir.path().join("app");
2369 let app_config_dir = app_dir.join("config");
2370 fs::create_dir_all(&app_config_dir).unwrap();
2371 fs::write(app_config_dir.join("cgx.toml"), "resolve_cache_timeout = \"9m\"").unwrap();
2372
2373 let user_config_dir = temp_dir.path().join("user");
2375 fs::create_dir_all(&user_config_dir).unwrap();
2376 fs::write(
2377 user_config_dir.join("cgx.toml"),
2378 "resolve_cache_timeout = \"11m\"",
2379 )
2380 .unwrap();
2381
2382 let cwd = temp_dir.path().join("work");
2383 fs::create_dir_all(&cwd).unwrap();
2384
2385 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2386 config_overrides.app_dir = Some(app_dir.clone());
2387 config_overrides.user_config_dir = Some(user_config_dir.clone());
2388
2389 let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2390
2391 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(11 * 60));
2393 assert_eq!(config.config_dir, user_config_dir);
2394
2395 assert_eq!(config.cache_dir, app_dir.join("cache"));
2397 assert_eq!(config.bin_dir, app_dir.join("bins"));
2398 assert_eq!(config.build_dir, app_dir.join("build"));
2399 }
2400 }
2401
2402 mod combined_tests {
2403 use super::*;
2404
2405 #[test]
2406 fn test_all_three_overrides() {
2407 let temp_dir = tempfile::tempdir().unwrap();
2408
2409 let system_config_dir = temp_dir.path().join("system");
2411 fs::create_dir_all(&system_config_dir).unwrap();
2412 fs::write(
2413 system_config_dir.join("cgx.toml"),
2414 "[tools]\nsystem_tool = \"1\"\n[aliases]\ndummytool = \"system\"",
2415 )
2416 .unwrap();
2417
2418 let app_dir = temp_dir.path().join("app");
2420 let app_config_dir = app_dir.join("config");
2421 fs::create_dir_all(&app_config_dir).unwrap();
2422 fs::write(app_config_dir.join("cgx.toml"), "[tools]\napp_tool = \"1\"").unwrap();
2423
2424 let user_config_dir = temp_dir.path().join("user");
2426 fs::create_dir_all(&user_config_dir).unwrap();
2427 fs::write(
2428 user_config_dir.join("cgx.toml"),
2429 "resolve_cache_timeout = \"12m\"\n[tools]\nuser_tool = \"1\"\n[aliases]\ndummytool = \
2430 \"user\"",
2431 )
2432 .unwrap();
2433
2434 let cwd = temp_dir.path().join("work");
2435 fs::create_dir_all(&cwd).unwrap();
2436
2437 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2438 config_overrides.system_config_dir = Some(system_config_dir);
2439 config_overrides.app_dir = Some(app_dir.clone());
2440 config_overrides.user_config_dir = Some(user_config_dir.clone());
2441
2442 let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2443
2444 assert!(config.tools.contains_key("system_tool"));
2446 assert!(config.tools.contains_key("user_tool"));
2447 assert_eq!(config.tools.len(), 2);
2448
2449 assert_eq!(config.aliases.get("dummytool"), Some(&"user".to_string()));
2451
2452 assert_eq!(config.config_dir, user_config_dir);
2454
2455 assert_eq!(config.cache_dir, app_dir.join("cache"));
2457 assert_eq!(config.bin_dir, app_dir.join("bins"));
2458 assert_eq!(config.build_dir, app_dir.join("build"));
2459 }
2460
2461 #[test]
2462 fn test_hierarchy_still_works_with_overrides() {
2463 let temp_dir = tempfile::tempdir().unwrap();
2464
2465 let app_dir = temp_dir.path().join("app");
2467
2468 let root = temp_dir.path().join("work");
2470 fs::create_dir_all(&root).unwrap();
2471 fs::write(root.join("cgx.toml"), "[tools]\nroot_tool = \"1\"").unwrap();
2472
2473 let sub = root.join("sub");
2474 fs::create_dir_all(&sub).unwrap();
2475 fs::write(sub.join("cgx.toml"), "[tools]\nsub_tool = \"1\"").unwrap();
2476
2477 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2478 config_overrides.app_dir = Some(app_dir);
2479
2480 let config = Config::load_from_dir(&sub, &config_overrides).unwrap();
2481
2482 assert!(config.tools.contains_key("root_tool"));
2484 assert!(config.tools.contains_key("sub_tool"));
2485 assert_eq!(config.tools.len(), 2);
2486 }
2487
2488 #[test]
2489 fn test_app_dir_takes_precedence_over_config_file() {
2490 let temp_dir = tempfile::tempdir().unwrap();
2491
2492 let app_dir = temp_dir.path().join("app");
2494 let app_config_dir = app_dir.join("config");
2495 fs::create_dir_all(&app_config_dir).unwrap();
2496
2497 let config_file = temp_dir.path().join("explicit.toml");
2499 let test_config = ConfigFile {
2500 cache_dir: Some(temp_dir.path().join("my-cache")),
2501 bin_dir: Some(temp_dir.path().join("my-bins")),
2502 build_dir: Some(temp_dir.path().join("my-build")),
2503 ..Default::default()
2504 };
2505 fs::write(&config_file, toml::to_string(&test_config).unwrap()).unwrap();
2506
2507 let cwd = temp_dir.path().join("work");
2508 fs::create_dir_all(&cwd).unwrap();
2509
2510 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2511 config_overrides.app_dir = Some(app_dir.clone());
2512 config_overrides.config_file = Some(config_file);
2513
2514 let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2515
2516 assert_eq!(config.cache_dir, app_dir.join("cache"));
2518 assert_eq!(config.bin_dir, app_dir.join("bins"));
2519 assert_eq!(config.build_dir, app_dir.join("build"));
2520 }
2521
2522 #[test]
2523 fn test_config_file_paths_used_when_no_app_dir() {
2524 let temp_dir = tempfile::tempdir().unwrap();
2525
2526 let config_file = temp_dir.path().join("explicit.toml");
2528 let test_config = ConfigFile {
2529 cache_dir: Some(temp_dir.path().join("my-cache")),
2530 bin_dir: Some(temp_dir.path().join("my-bins")),
2531 build_dir: Some(temp_dir.path().join("my-build")),
2532 ..Default::default()
2533 };
2534 fs::write(&config_file, toml::to_string(&test_config).unwrap()).unwrap();
2535
2536 let cwd = temp_dir.path().join("work");
2537 fs::create_dir_all(&cwd).unwrap();
2538
2539 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2540 config_overrides.config_file = Some(config_file);
2542
2543 let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2544
2545 assert_eq!(config.cache_dir, temp_dir.path().join("my-cache"));
2547 assert_eq!(config.bin_dir, temp_dir.path().join("my-bins"));
2548 assert_eq!(config.build_dir, temp_dir.path().join("my-build"));
2549 }
2550 }
2551 }
2552
2553 mod http_config_deserialization_tests {
2554 use super::*;
2555
2556 #[test]
2557 fn test_deserialize_http_config_full() {
2558 let toml_content = r#"
2559 [http]
2560 timeout = "2m"
2561 retries = 5
2562 backoff_base = "1s"
2563 backoff_max = "30s"
2564 proxy = "http://proxy.example.com:3128"
2565 "#;
2566
2567 let config: ConfigFile = toml::from_str(toml_content).unwrap();
2568 let http = config.http.unwrap();
2569 assert_eq!(http.timeout, Some(Duration::from_secs(120)));
2570 assert_eq!(http.retries, Some(5));
2571 assert_eq!(http.backoff_base, Some(Duration::from_secs(1)));
2572 assert_eq!(http.backoff_max, Some(Duration::from_secs(30)));
2573 assert_eq!(http.proxy, Some("http://proxy.example.com:3128".to_string()));
2574 }
2575
2576 #[test]
2577 fn test_deserialize_http_config_partial() {
2578 let toml_content = r#"
2579 [http]
2580 timeout = "45s"
2581 retries = 3
2582 "#;
2583
2584 let config: ConfigFile = toml::from_str(toml_content).unwrap();
2585 let http = config.http.unwrap();
2586 assert_eq!(http.timeout, Some(Duration::from_secs(45)));
2587 assert_eq!(http.retries, Some(3));
2588 assert_eq!(http.backoff_base, None);
2589 assert_eq!(http.backoff_max, None);
2590 assert_eq!(http.proxy, None);
2591 }
2592
2593 #[test]
2594 fn test_deserialize_http_config_empty_section() {
2595 let toml_content = r#"
2596 [http]
2597 "#;
2598
2599 let config: ConfigFile = toml::from_str(toml_content).unwrap();
2600 let http = config.http.unwrap();
2601 assert_eq!(http.timeout, None);
2602 assert_eq!(http.retries, None);
2603 assert_eq!(http.backoff_base, None);
2604 assert_eq!(http.backoff_max, None);
2605 assert_eq!(http.proxy, None);
2606 }
2607
2608 #[test]
2609 fn test_deserialize_http_config_unknown_field_rejected() {
2610 let toml_content = r#"
2611 [http]
2612 timeoutt = "30s"
2613 "#;
2614
2615 let result: std::result::Result<ConfigFile, _> = toml::from_str(toml_content);
2616 assert!(result.is_err(), "Expected error for unknown field 'timeoutt'");
2617 }
2618
2619 #[test]
2620 fn test_http_config_default_values() {
2621 let defaults = HttpConfig::default();
2622 assert_eq!(defaults.timeout, DEFAULT_HTTP_TIMEOUT);
2623 assert_eq!(defaults.retries, DEFAULT_HTTP_RETRIES);
2624 assert_eq!(defaults.backoff_base, DEFAULT_HTTP_BACKOFF_BASE);
2625 assert_eq!(defaults.backoff_max, DEFAULT_HTTP_BACKOFF_MAX);
2626 assert_eq!(defaults.proxy, None);
2627 }
2628 }
2629
2630 mod build_http_config_tests {
2631 use std::io::Write;
2632
2633 use assert_matches::assert_matches;
2634
2635 use super::*;
2636
2637 fn create_temp_config(toml_content: &str) -> tempfile::TempDir {
2638 let temp_dir = tempfile::tempdir().unwrap();
2639 let config_path = temp_dir.path().join("cgx.toml");
2640 let mut file = std::fs::File::create(&config_path).unwrap();
2641 file.write_all(toml_content.as_bytes()).unwrap();
2642 temp_dir
2643 }
2644
2645 #[test]
2646 fn test_http_config_all_defaults() {
2647 let temp_dir = tempfile::tempdir().unwrap();
2648 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2649 config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
2650 config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
2651
2652 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2653 assert_eq!(config.http.timeout, Duration::from_secs(30));
2654 assert_eq!(config.http.retries, 2);
2655 assert_eq!(config.http.backoff_base, Duration::from_millis(500));
2656 assert_eq!(config.http.backoff_max, Duration::from_secs(5));
2657 assert_eq!(config.http.proxy, None);
2658 }
2659
2660 #[test]
2661 fn test_http_config_from_config_file() {
2662 let toml_content = r#"
2663 [http]
2664 timeout = "2m"
2665 retries = 5
2666 proxy = "http://proxy:3128"
2667 "#;
2668 let temp_dir = create_temp_config(toml_content);
2669 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2670 config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
2671 config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
2672
2673 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2674 assert_eq!(config.http.timeout, Duration::from_secs(120));
2675 assert_eq!(config.http.retries, 5);
2676 assert_eq!(config.http.proxy, Some("http://proxy:3128".to_string()));
2677 }
2678
2679 #[test]
2680 fn test_http_config_cli_overrides_config_file() {
2681 let toml_content = r#"
2682 [http]
2683 timeout = "2m"
2684 retries = 5
2685 proxy = "http://proxy:3128"
2686 "#;
2687 let temp_dir = create_temp_config(toml_content);
2688 let mut config_overrides = Cli::parse_from_test_args([
2689 "--http-timeout",
2690 "10s",
2691 "--http-retries",
2692 "0",
2693 "--http-proxy",
2694 "socks5://other:1080",
2695 "test-crate",
2696 ])
2697 .to_config_overrides();
2698 config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
2699 config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
2700
2701 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2702 assert_eq!(config.http.timeout, Duration::from_secs(10));
2703 assert_eq!(config.http.retries, 0);
2704 assert_eq!(config.http.proxy, Some("socks5://other:1080".to_string()));
2705 }
2706
2707 #[test]
2708 fn test_http_config_cli_overrides_partial() {
2709 let toml_content = r#"
2710 [http]
2711 timeout = "2m"
2712 retries = 5
2713 proxy = "http://proxy:3128"
2714 "#;
2715 let temp_dir = create_temp_config(toml_content);
2716 let mut config_overrides =
2717 Cli::parse_from_test_args(["--http-timeout", "10s", "test-crate"]).to_config_overrides();
2718 config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
2719 config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
2720
2721 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2722 assert_eq!(config.http.timeout, Duration::from_secs(10));
2723 assert_eq!(config.http.retries, 5);
2724 assert_eq!(config.http.proxy, Some("http://proxy:3128".to_string()));
2725 }
2726
2727 #[test]
2728 fn test_http_config_invalid_timeout_duration() {
2729 let temp_dir = tempfile::tempdir().unwrap();
2730 let mut config_overrides =
2731 Cli::parse_from_test_args(["--http-timeout", "not-a-duration", "test-crate"])
2732 .to_config_overrides();
2733 config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
2734 config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
2735
2736 let result = Config::load_from_dir(temp_dir.path(), &config_overrides);
2737 assert_matches!(result, Err(crate::error::Error::InvalidHttpTimeout { .. }));
2738 }
2739
2740 #[test]
2741 fn test_http_config_zero_retries() {
2742 let temp_dir = tempfile::tempdir().unwrap();
2743 let mut config_overrides =
2744 Cli::parse_from_test_args(["--http-retries", "0", "test-crate"]).to_config_overrides();
2745 config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
2746 config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
2747
2748 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2749 assert_eq!(config.http.retries, 0);
2750 }
2751
2752 #[test]
2753 fn test_http_config_backoff_from_config_file() {
2754 let toml_content = r#"
2755 [http]
2756 backoff_base = "2s"
2757 backoff_max = "60s"
2758 "#;
2759 let temp_dir = create_temp_config(toml_content);
2760 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2761 config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
2762 config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
2763
2764 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2765 assert_eq!(config.http.backoff_base, Duration::from_secs(2));
2766 assert_eq!(config.http.backoff_max, Duration::from_secs(60));
2767 }
2768
2769 #[test]
2770 fn test_http_config_backoff_defaults_when_not_in_file() {
2771 let toml_content = r#"
2772 [http]
2773 timeout = "45s"
2774 "#;
2775 let temp_dir = create_temp_config(toml_content);
2776 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
2777 config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
2778 config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
2779
2780 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2781 assert_eq!(config.http.backoff_base, Duration::from_millis(500));
2782 assert_eq!(config.http.backoff_max, Duration::from_secs(5));
2783 }
2784
2785 #[test]
2786 fn test_http_config_hierarchy_merging_preserves_parent_fields() {
2789 let temp_dir = tempfile::tempdir().unwrap();
2790
2791 let parent = temp_dir.path().join("parent");
2792 std::fs::create_dir_all(&parent).unwrap();
2793 std::fs::write(
2794 parent.join("cgx.toml"),
2795 r#"
2796 [http]
2797 timeout = "1m"
2798 retries = 3
2799 "#,
2800 )
2801 .unwrap();
2802
2803 let child = parent.join("child");
2804 std::fs::create_dir_all(&child).unwrap();
2805 std::fs::write(
2806 child.join("cgx.toml"),
2807 r#"
2808 [http]
2809 timeout = "45s"
2810 "#,
2811 )
2812 .unwrap();
2813
2814 let config_overrides = with_isolated_global_config(
2815 Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
2816 temp_dir.path(),
2817 );
2818
2819 let config = Config::load_from_dir(&child, &config_overrides).unwrap();
2820 assert_eq!(config.http.timeout, Duration::from_secs(45));
2823 assert_eq!(config.http.retries, 3);
2824 }
2825
2826 #[test]
2827 fn test_http_config_hierarchy_merging_child_overrides_parent_fields() {
2830 let temp_dir = tempfile::tempdir().unwrap();
2831
2832 let parent = temp_dir.path().join("parent");
2833 std::fs::create_dir_all(&parent).unwrap();
2834 std::fs::write(
2835 parent.join("cgx.toml"),
2836 r#"
2837 [http]
2838 timeout = "1m"
2839 retries = 3
2840 "#,
2841 )
2842 .unwrap();
2843
2844 let child = parent.join("child");
2845 std::fs::create_dir_all(&child).unwrap();
2846 std::fs::write(
2847 child.join("cgx.toml"),
2848 r#"
2849 [http]
2850 timeout = "45s"
2851 retries = 5
2852 "#,
2853 )
2854 .unwrap();
2855
2856 let config_overrides = with_isolated_global_config(
2857 Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
2858 temp_dir.path(),
2859 );
2860
2861 let config = Config::load_from_dir(&child, &config_overrides).unwrap();
2862 assert_eq!(config.http.timeout, Duration::from_secs(45));
2863 assert_eq!(config.http.retries, 5);
2864 }
2865 }
2866
2867 mod build_http_config_env_tests {
2868 use std::io::Write;
2869
2870 use sealed_test::prelude::*;
2871
2872 use super::*;
2873
2874 fn create_temp_config(toml_content: &str) -> tempfile::TempDir {
2875 let temp_dir = tempfile::tempdir().unwrap();
2876 let config_path = temp_dir.path().join("cgx.toml");
2877 let mut file = std::fs::File::create(&config_path).unwrap();
2878 file.write_all(toml_content.as_bytes()).unwrap();
2879 temp_dir
2880 }
2881
2882 #[sealed_test(env = [("CARGO_HTTP_TIMEOUT", "45")])]
2883 fn test_env_timeout_used_when_no_cli_or_config() {
2885 let temp_dir = tempfile::tempdir().unwrap();
2886 let config_overrides = with_isolated_global_config(
2887 Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
2888 temp_dir.path(),
2889 );
2890
2891 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2892 assert_eq!(config.http.timeout, Duration::from_secs(45));
2893 }
2894
2895 #[sealed_test(env = [("CARGO_NET_RETRY", "7")])]
2896 fn test_env_retries_used_when_no_cli_or_config() {
2898 let temp_dir = tempfile::tempdir().unwrap();
2899 let config_overrides = with_isolated_global_config(
2900 Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
2901 temp_dir.path(),
2902 );
2903
2904 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2905 assert_eq!(config.http.retries, 7);
2906 }
2907
2908 #[sealed_test(env = [("CARGO_HTTP_PROXY", "socks5://env-proxy:1080")])]
2909 fn test_env_proxy_used_when_no_cli_or_config() {
2911 let temp_dir = tempfile::tempdir().unwrap();
2912 let config_overrides = with_isolated_global_config(
2913 Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
2914 temp_dir.path(),
2915 );
2916
2917 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2918 assert_eq!(config.http.proxy, Some("socks5://env-proxy:1080".to_string()));
2919 }
2920
2921 #[sealed_test(env = [
2922 ("CARGO_HTTP_TIMEOUT", "45"),
2923 ("CARGO_NET_RETRY", "7"),
2924 ("CARGO_HTTP_PROXY", "http://env-proxy:3128")
2925 ])]
2926 fn test_cli_overrides_env() {
2928 let temp_dir = tempfile::tempdir().unwrap();
2929 let config_overrides = with_isolated_global_config(
2930 Cli::parse_from_test_args([
2931 "--http-timeout",
2932 "10s",
2933 "--http-retries",
2934 "1",
2935 "--http-proxy",
2936 "socks5://cli-proxy:1080",
2937 "test-crate",
2938 ])
2939 .to_config_overrides(),
2940 temp_dir.path(),
2941 );
2942
2943 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2944 assert_eq!(config.http.timeout, Duration::from_secs(10));
2945 assert_eq!(config.http.retries, 1);
2946 assert_eq!(config.http.proxy, Some("socks5://cli-proxy:1080".to_string()));
2947 }
2948
2949 #[sealed_test(env = [
2950 ("CARGO_HTTP_TIMEOUT", "45"),
2951 ("CARGO_NET_RETRY", "7"),
2952 ("CARGO_HTTP_PROXY", "http://env-proxy:3128")
2953 ])]
2954 fn test_config_file_overrides_env() {
2956 let toml_content = r#"
2957 [http]
2958 timeout = "2m"
2959 retries = 5
2960 proxy = "http://config-proxy:8080"
2961 "#;
2962 let temp_dir = create_temp_config(toml_content);
2963 let config_overrides = with_isolated_global_config(
2964 Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
2965 temp_dir.path(),
2966 );
2967
2968 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2969 assert_eq!(config.http.timeout, Duration::from_secs(120));
2970 assert_eq!(config.http.retries, 5);
2971 assert_eq!(config.http.proxy, Some("http://config-proxy:8080".to_string()));
2972 }
2973
2974 #[sealed_test(env = [("CARGO_HTTP_TIMEOUT", "not-a-number")])]
2975 fn test_invalid_env_timeout_falls_back_to_default() {
2977 let temp_dir = tempfile::tempdir().unwrap();
2978 let config_overrides = with_isolated_global_config(
2979 Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
2980 temp_dir.path(),
2981 );
2982
2983 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2984 assert_eq!(config.http.timeout, DEFAULT_HTTP_TIMEOUT);
2985 }
2986
2987 #[sealed_test(env = [("CARGO_NET_RETRY", "not-a-number")])]
2988 fn test_invalid_env_retries_falls_back_to_default() {
2990 let temp_dir = tempfile::tempdir().unwrap();
2991 let config_overrides = with_isolated_global_config(
2992 Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
2993 temp_dir.path(),
2994 );
2995
2996 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
2997 assert_eq!(config.http.retries, DEFAULT_HTTP_RETRIES);
2998 }
2999 }
3000
3001 mod build_http_config_direct_tests {
3002 use super::*;
3003
3004 #[test]
3005 fn test_config_file_timeout_overrides_defaults() {
3006 let config_file = HttpConfigFile {
3007 timeout: Some(Duration::from_secs(120)),
3008 ..Default::default()
3009 };
3010 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
3011 let http = Config::build_http_config(&config_file, &config_overrides).unwrap();
3012 assert_eq!(http.timeout, Duration::from_secs(120));
3013 assert_eq!(http.retries, DEFAULT_HTTP_RETRIES);
3014 }
3015
3016 #[test]
3017 fn test_config_file_retries_overrides_defaults() {
3018 let config_file = HttpConfigFile {
3019 retries: Some(10),
3020 ..Default::default()
3021 };
3022 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
3023 let http = Config::build_http_config(&config_file, &config_overrides).unwrap();
3024 assert_eq!(http.retries, 10);
3025 }
3026
3027 #[test]
3028 fn test_config_file_proxy_overrides_defaults() {
3029 let config_file = HttpConfigFile {
3030 proxy: Some("http://proxy:3128".to_string()),
3031 ..Default::default()
3032 };
3033 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
3034 let http = Config::build_http_config(&config_file, &config_overrides).unwrap();
3035 assert_eq!(http.proxy, Some("http://proxy:3128".to_string()));
3036 }
3037
3038 #[test]
3039 fn test_cli_timeout_overrides_config_file() {
3040 let config_file = HttpConfigFile {
3041 timeout: Some(Duration::from_secs(120)),
3042 ..Default::default()
3043 };
3044 let config_overrides =
3045 Cli::parse_from_test_args(["--http-timeout", "10s", "test-crate"]).to_config_overrides();
3046 let http = Config::build_http_config(&config_file, &config_overrides).unwrap();
3047 assert_eq!(http.timeout, Duration::from_secs(10));
3048 }
3049
3050 #[test]
3051 fn test_cli_retries_overrides_config_file() {
3052 let config_file = HttpConfigFile {
3053 retries: Some(10),
3054 ..Default::default()
3055 };
3056 let config_overrides =
3057 Cli::parse_from_test_args(["--http-retries", "0", "test-crate"]).to_config_overrides();
3058 let http = Config::build_http_config(&config_file, &config_overrides).unwrap();
3059 assert_eq!(http.retries, 0);
3060 }
3061
3062 #[test]
3063 fn test_cli_proxy_overrides_config_file() {
3064 let config_file = HttpConfigFile {
3065 proxy: Some("http://old:3128".to_string()),
3066 ..Default::default()
3067 };
3068 let config_overrides =
3069 Cli::parse_from_test_args(["--http-proxy", "socks5://new:1080", "test-crate"])
3070 .to_config_overrides();
3071 let http = Config::build_http_config(&config_file, &config_overrides).unwrap();
3072 assert_eq!(http.proxy, Some("socks5://new:1080".to_string()));
3073 }
3074
3075 #[test]
3076 fn test_empty_config_file_yields_defaults() {
3077 let config_file = HttpConfigFile::default();
3078 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
3079 let http = Config::build_http_config(&config_file, &config_overrides).unwrap();
3080 assert_eq!(http.timeout, DEFAULT_HTTP_TIMEOUT);
3081 assert_eq!(http.retries, DEFAULT_HTTP_RETRIES);
3082 assert_eq!(http.backoff_base, DEFAULT_HTTP_BACKOFF_BASE);
3083 assert_eq!(http.backoff_max, DEFAULT_HTTP_BACKOFF_MAX);
3084 assert_eq!(http.proxy, None);
3085 }
3086
3087 #[test]
3088 fn test_backoff_from_config_file() {
3089 let config_file = HttpConfigFile {
3090 backoff_base: Some(Duration::from_secs(2)),
3091 backoff_max: Some(Duration::from_secs(60)),
3092 ..Default::default()
3093 };
3094 let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
3095 let http = Config::build_http_config(&config_file, &config_overrides).unwrap();
3096 assert_eq!(http.backoff_base, Duration::from_secs(2));
3097 assert_eq!(http.backoff_max, Duration::from_secs(60));
3098 }
3099 }
3100
3101 mod error_tests {
3102 use assert_matches::assert_matches;
3103
3104 use super::*;
3105
3106 #[test]
3107 fn test_invalid_toml_syntax() {
3108 let test_case = crate::testdata::ConfigTestCase::invalid_toml();
3109
3110 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
3111 config_overrides.config_file = Some(test_case.path().to_path_buf());
3112
3113 let result = Config::load(&config_overrides);
3114 assert_matches!(result, Err(crate::error::Error::ConfigExtract { .. }));
3115 }
3116
3117 #[test]
3118 fn test_invalid_config_options_raise_error() {
3119 let test_case = crate::testdata::ConfigTestCase::invalid_options();
3120
3121 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
3122 config_overrides.config_file = Some(test_case.path().to_path_buf());
3123
3124 let result = Config::load(&config_overrides);
3125 assert_matches!(result, Err(crate::error::Error::ConfigExtract { .. }));
3126 }
3127
3128 #[test]
3129 fn test_nonexistent_explicit_config_file() {
3130 let test_case = crate::testdata::ConfigTestCase::nonexistent();
3131
3132 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
3133 config_overrides.config_file = Some(test_case.path().to_path_buf());
3134
3135 let config = Config::load(&config_overrides).unwrap();
3136
3137 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(60 * 60));
3138 }
3139
3140 #[test]
3141 fn test_no_config_files_uses_defaults() {
3142 let temp_dir = tempfile::tempdir().unwrap();
3143
3144 let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
3145 config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
3149 config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
3150
3151 let config = Config::load_from_dir(temp_dir.path(), &config_overrides).unwrap();
3152
3153 assert_eq!(config.resolve_cache_timeout, Duration::from_secs(60 * 60));
3154 assert!(!config.offline);
3155 assert!(config.locked); assert_eq!(config.toolchain, None);
3157 assert_eq!(config.tools.len(), 0);
3158 assert_eq!(config.aliases.len(), 0);
3159 }
3160 }
3161}