Skip to main content

cgx_core/
config.rs

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/// The user's preference for using pre-built binaries.
30#[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    /// Use pre-built binaries when possible (subject to the configured allowed binary providers),
37    /// fall back to building from source when no suitable binary is found.
38    #[default]
39    Auto,
40    /// Only ever use pre-built binaries.  If a particular crate invocation cannot be satisfied
41    /// with a pre-built binary then fail the invocation rather than building from source
42    Always,
43    /// Never look for or use pre-built binaries, always build from source.
44    Never,
45}
46
47/// Represents the sources to check for pre-built binaries before building from source.
48#[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    /// Use the crate's declared `[package.metadata.binstall]` metadata (if present) to find
67    /// pre-built binaries
68    Binstall,
69    /// Check GitHub releases on the crate's repository
70    GithubReleases,
71    /// Check GitLab releases on the crate's repository
72    GitlabReleases,
73    /// Use the community-driven quickinstall repository
74    Quickinstall,
75}
76
77/// Configuration for how (and whether) to look for pre-built binaries when running a crate.
78#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
79#[serde(default, deny_unknown_fields)]
80pub struct PrebuiltBinariesConfig {
81    /// Whether and how to use pre-built binaries.
82    pub use_prebuilt_binaries: UsePrebuiltBinaries,
83
84    /// List of sources to check for pre-built binaries before building from source.
85    ///
86    /// If this list is empty and [`Self::use_prebuilt_binaries`] is not set to `Never`, config
87    /// loading will fail with an error. To disable prebuilt binaries, set
88    /// [`Self::use_prebuilt_binaries`] to `Never` rather than using an empty provider list.
89    pub binary_providers: Vec<BinaryProvider>,
90
91    /// If enabled, when downloading a binary check for a checksum file and if found verify that
92    /// the download matches the checksum.
93    ///
94    /// This adds minimal overhead and is recommended for security, therefore is on by default.
95    pub verify_checksums: bool,
96
97    /// If enabled, when downloading a binary check for a signature file and if found verify that
98    /// the download matches the signature.
99    ///
100    /// This is not quite as simple as [`Self::verify_checksums`] since it requires having the
101    /// minisign tooling  available to perform verification.  However it adds stronger security
102    /// against malicious binaries.
103    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/// HTTP client settings for registry queries, binary downloads, API calls, and git operations.
123///
124/// For git operations, proxy, user agent, and connect timeout are applied via gix config
125/// overrides (backed by the curl HTTP backend). Retry and backoff settings are applied by
126/// cgx's own retry wrapper around git fetches. The timeout setting is intentionally used for
127/// both connection timeout and stalled-transfer timeout detection for git-over-HTTP.
128#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
129#[serde(default, deny_unknown_fields)]
130pub struct HttpConfig {
131    /// Request timeout for HTTP operations.
132    ///
133    /// For git operations over HTTP/S this value is also used as both:
134    /// - connection timeout
135    /// - stalled-transfer timeout threshold
136    #[serde(with = "humantime_serde")]
137    pub timeout: Duration,
138
139    /// Maximum number of retries for transient HTTP failures (429, 5xx, connection errors).
140    pub retries: usize,
141
142    /// Base delay for exponential backoff between retries.
143    #[serde(with = "humantime_serde")]
144    pub backoff_base: Duration,
145
146    /// Maximum delay between retries (caps exponential growth).
147    #[serde(with = "humantime_serde")]
148    pub backoff_max: Duration,
149
150    /// HTTP or SOCKS5 proxy URL for all HTTP requests.
151    #[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/// Raw HTTP config from config file, with optional fields for detecting whether values were set.
168#[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/// Configuration for a specific tool, matching Cargo.toml dependency format.
191///
192/// This can be a simple version string like `"1.0"` or a more complex specification
193/// with version, features, registry, git repo, etc.
194///
195/// Serialization uses serde's `untagged` representation (a bare string or a bare table).
196/// Deserialization is hand-written (see the [`Deserialize`] impl) rather than derived
197/// `untagged` so that a typo'd or mistyped key in a detailed table produces a precise error naming
198/// the offending field, instead of the opaque `data did not match any variant` that `untagged`
199/// derive emits.
200#[derive(Debug, Clone, PartialEq, Serialize)]
201#[serde(untagged)]
202pub enum ToolConfig {
203    /// Simple version specification (e.g., "1.0", "*")
204    Version(String),
205    /// Detailed configuration with version, features, registry, etc.
206    Detailed(ToolConfigDetailed),
207}
208
209/// The detailed (table) form of a [`ToolConfig`].
210#[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    /// Whether to enable the crate's default features, matching Cargo's `default-features`
218    /// dependency key. Defaults to `true`; set to `false` to build with `--no-default-features`.
219    #[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    /// Return configured features for a [`ToolConfig::Detailed`] tool.
276    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    /// Return the configured `default-features` setting for this tool.
284    ///
285    /// `true` (the default) means the crate's default features are enabled; `false` means
286    /// building with default features disabled, equivalent to `--no-default-features`.
287    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
295/// Predicate for `skip_serializing_if` on the `default-features` field, so the default `true` is
296/// omitted from rendered config; named because serde's attribute requires a function path.
297///
298/// Yes, an `is_true(bool) -> bool` function is like a particularly stupid Daily WTF episode,
299/// but unfortunately it's necessary here.
300fn is_true(value: &bool) -> bool {
301    *value
302}
303
304/// Default for the `default-features` field of [`ToolConfig::Detailed`]; named because serde's
305/// `default` attribute requires a function path.
306///
307/// This maybe even dumber than [`is_true`], but again this needs to be in a form of a function not
308/// a constant so here we are.
309fn default_true() -> bool {
310    true
311}
312
313/// Intermediate structure for deserializing config files from TOML.
314///
315/// This matches the structure of cgx.toml files and is used during the deserialization
316/// process. Fields are then mapped to the final [`Config`] struct.
317#[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    /// Returns the base configuration with sensible defaults.
366    ///
367    /// This is distinct from [`Default`] which returns all `None` values. The `Default` impl
368    /// is used by serde to represent fields missing from a config file, so it must be all `None`.
369    ///
370    /// This method provides the actual default values that serve as the lowest-precedence layer
371    /// in the config hierarchy, before any config files are applied.
372    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
391/// Custom deserializer for optional [`PathBuf`] that expands ~ to home directory.
392fn 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
408/// Attempt to read a config file, rewriting tool paths if necessary to produce tool paths with
409/// absolute paths.
410///
411/// Each config file in the set of evaluated config files can specify a `[tools]` section, and a
412/// tool can optionally specify a path to a local directory where the crate code is located.  That
413/// can be absolute or relative, but if it's relative it's relative to the directory where the
414/// config TOML file is, not the CWD of the running process.
415///
416/// This function applies that logic, replacing relative paths by evaluating them relative to the
417/// config file path, to produce the correct absolute path.
418///
419/// If any rewriting was done, returns `Some` with the `ConfigFile` config containing just the tool
420/// with the rewritten path.  This can be used with figment's merge feature as a way to patch in
421/// the correct full path in place of the relative path that was loaded previously from the config
422/// file.
423fn 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    // This private extraction intentionally sees only enough of the file to normalize
484    // `[tools].*.path`. Final `ConfigFile` extraction below still owns schema validation, so a
485    // typo in the full tool table is not hidden by this sparse patch.
486    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/// Configuration settings for cgx.
524///
525/// Configuration is loaded from multiple sources in order of precedence (later sources override
526/// earlier ones):
527/// 1. Hard-coded defaults
528/// 2. System-wide config file (`/etc/cgx.toml` on Linux/macOS)
529/// 3. User config file (`$XDG_CONFIG_HOME/cgx/cgx.toml` or platform equivalent)
530/// 4. Directory hierarchy from filesystem root to current directory (each `cgx.toml` found)
531/// 5. Command-line arguments (highest priority)
532#[derive(Debug, Clone)]
533pub struct Config {
534    /// Directory where config files are stored
535    pub config_dir: PathBuf,
536
537    /// The cache directory where various levels of cache are located
538    pub cache_dir: PathBuf,
539
540    /// Directory where compiled binaries that can be re-used are stored
541    pub bin_dir: PathBuf,
542
543    /// Directory for ephemeral build artifacts.
544    ///
545    /// Temporary directories for source extraction and compilation are created here.
546    /// Only the final compiled binary is retained; all other build artifacts are cleaned up.
547    pub build_dir: PathBuf,
548
549    /// How long to keep resolved crate information in the cache before re-resolving
550    pub resolve_cache_timeout: Duration,
551
552    pub offline: bool,
553
554    pub locked: bool,
555
556    pub refresh: bool,
557
558    /// Rust toolchain to use for building (e.g., "nightly", "1.70.0", "stable")
559    pub toolchain: Option<String>,
560
561    /// Logging filter expression from the config file (e.g., "info", "debug", "cgx=debug,info").
562    pub log_level: Option<String>,
563
564    /// Logging/build verbosity from the repeated `-v` CLI flag.
565    ///
566    /// The single source from which both the tracing level and cargo's `-v` count are derived.
567    pub verbosity: Verbosity,
568
569    /// Default registry to use instead of crates.io when no registry is explicitly specified
570    pub default_registry: Option<String>,
571
572    /// How or whether to look for pre-built binaries published for the crates being run.
573    pub prebuilt_binaries: PrebuiltBinariesConfig,
574
575    /// HTTP client configuration for registry queries, binary downloads, and API calls.
576    pub http: HttpConfig,
577
578    /// Pinned tool versions and configurations.
579    ///
580    /// Tools listed here will use the specified version/source instead of being resolved
581    /// dynamically. This allows pinning critical tools to specific versions.
582    pub tools: HashMap<String, ToolConfig>,
583
584    /// Tool name aliases.
585    ///
586    /// Maps convenient names to actual crate names. For example, `rg` -> `ripgrep`.
587    /// Note that aliases shadow actual crate names, so aliased crates become inaccessible.
588    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/// How the lockfile should be treated, collapsed from the mutually-exclusive
615/// `--locked`/`--frozen`/`--unlocked` command-line flags.
616#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
617pub enum LockMode {
618    /// No lockfile flag was given; fall back to the config-file setting (default locked).
619    #[default]
620    Default,
621    /// `--locked`: honor `Cargo.lock`.
622    Locked,
623    /// `--frozen`: equivalent to `--locked` plus `--offline`.
624    Frozen,
625    /// `--unlocked`: ignore `Cargo.lock` and resolve dependencies fresh.
626    Unlocked,
627}
628
629/// Logging/build verbosity
630///
631/// Represents both how verbose `cgx`'s own log output should be, and also how verbose the `cargo
632/// build` output should be.
633#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
634pub enum Verbosity {
635    /// Default: warnings and errors only (no `-v`).
636    #[default]
637    Normal,
638    /// `-v`: informational logging; one `-v` to cargo.
639    Verbose,
640    /// `-vv`: debug logging; two `-v`s to cargo.
641    VeryVerbose,
642    /// `-vvv` or more: trace logging; three `-v`s to cargo.
643    ExtremelyVerbose,
644}
645
646impl Verbosity {
647    /// Construct a [`Verbosity`] from the repeated-`-v` counter (clap's `ArgAction::Count`).
648    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/// Configuration overrides supplied on the command line.
659///
660/// This is the config-layer input to [`Config::load`]: it carries the subset of parsed arguments
661/// that influence configuration loading.  This is meant to be populated from the CLI arguments,
662/// without coupling this layer to the actual CLI or `clap`.
663#[derive(Clone, Debug, Default)]
664pub struct ConfigOverrides {
665    /// Read configuration from this TOML file only, bypassing the usual search paths
666    /// (`--config-file`).
667    pub config_file: Option<PathBuf>,
668
669    /// Override the system config directory location (`--system-config-dir`).
670    pub system_config_dir: Option<PathBuf>,
671
672    /// Override the base application directory (`--app-dir`).
673    pub app_dir: Option<PathBuf>,
674
675    /// Override the user config directory location (`--user-config-dir`).
676    pub user_config_dir: Option<PathBuf>,
677
678    /// HTTP request timeout as a raw string (e.g. `"30s"`, `"2m"`), parsed by [`Config`]
679    /// (`--http-timeout`).
680    pub http_timeout: Option<String>,
681
682    /// Maximum number of retries for transient HTTP failures (`--http-retries`).
683    pub http_retries: Option<usize>,
684
685    /// HTTP or SOCKS5 proxy URL for all HTTP requests (`--http-proxy`).
686    pub http_proxy: Option<String>,
687
688    /// How the lockfile should be treated (`--locked`/`--frozen`/`--unlocked`).
689    pub lockfile: LockMode,
690
691    /// Run without accessing the network (`--offline`); combines with [`Self::lockfile`].
692    pub offline: bool,
693
694    /// Force refresh of all cached data for this crate (`--refresh`).
695    pub refresh: bool,
696
697    /// Control use of pre-built binaries: never, always, or auto (`--prebuilt-binary`).
698    pub prebuilt_binary: Option<UsePrebuiltBinaries>,
699
700    /// Override the binary providers to check for pre-built binaries (`--prebuilt-binary-sources`).
701    pub prebuilt_binary_sources: Option<Vec<BinaryProvider>>,
702
703    /// Disable checksum verification when downloading pre-built binaries
704    /// (`--prebuilt-binary-no-verify-checksums`).
705    pub prebuilt_binary_no_verify_checksums: bool,
706
707    /// Disable signature verification when downloading pre-built binaries
708    /// (`--prebuilt-binary-no-verify-signatures`).
709    pub prebuilt_binary_no_verify_signatures: bool,
710
711    /// Logging/build verbosity from the repeated `-v` flag.
712    pub verbosity: Verbosity,
713}
714
715/// One distinct tool referenced in the config TOML `[tools]` and possibly also `[aliases]`
716/// sections
717#[derive(Debug, PartialEq, Eq)]
718pub(crate) struct ConfiguredTool {
719    /// The crate name as it appeared in the `[tools]` section
720    pub(crate) name: String,
721    /// The aliases that resolve to this tool (from the `[aliases]` section), sorted, excluding the
722    /// name itself.
723    pub(crate) aliases: Vec<String>,
724}
725
726impl Config {
727    /// Load the configuration, honoring config files and command line arguments.
728    ///
729    /// Configuration is loaded from multiple sources with the following precedence
730    /// (later sources override earlier ones):
731    /// 1. Hard-coded defaults
732    /// 2. System-wide config file
733    /// 3. User config file
734    /// 4. Directory hierarchy config files (from root to current directory)
735    /// 5. Command-line arguments (highest priority)
736    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    /// Render the merged configured tools and aliases as deterministic TOML.
743    ///
744    /// Both the `[tools]` and `[aliases]` headers are always present (even when empty), so the
745    /// output is a stable template a user can copy and edit. Each section's presence is decided
746    /// from the data (whether the map is empty), not by scanning the serializer's output.
747    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        /// Guarantee that a serialized section starts with its bare `[section]` header.
758        ///
759        /// `toml` only emits the bare `[tools]`/`[aliases]` line when a section has at least one
760        /// scalar entry; a section whose entries are all subtables (only detailed tool tables)
761        /// would otherwise start at `[tools.<name>]`. Prepending keeps the section visible and the
762        /// output a stable, editable template, without depending on how the serializer orders or
763        /// omits empty tables.
764        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    /// The configured tools in deterministic (key-sorted) order.
797    ///
798    /// The single source of ordering for both [`Config::tools_toml`] and `--list-tools` message
799    /// emission.
800    pub(crate) fn sorted_tools(&self) -> BTreeMap<&String, &ToolConfig> {
801        self.tools.iter().collect()
802    }
803
804    /// The configured aliases in deterministic (key-sorted) order.
805    pub(crate) fn sorted_aliases(&self) -> BTreeMap<&String, &String> {
806        self.aliases.iter().collect()
807    }
808
809    /// Group every configured tool and alias by the tool it resolves to, applying the same
810    /// single-step alias resolution as [`crate::cratespec::CrateSpec::load`].
811    ///
812    /// Each distinct resolved crate appears once, so `--prefetch-all` prefetches it a single time
813    /// regardless of how many aliases point at it.
814    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    /// Load config from the CLI args and a specified directory which may or may not contain config
831    /// files.
832    pub fn load_from_dir(cwd: &Path, overrides: &ConfigOverrides) -> Result<Self> {
833        let strategy = Self::get_user_dirs()?;
834
835        // Start with base config defaults, then merge config files
836        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            // Figment keeps source metadata on each leaf value, but `ConfigFile` stores plain
841            // `PathBuf`s, which discard that metadata. Merge a tiny patch right after each config
842            // file so tool paths are made absolute while they still know which file declared them.
843            if let Some(path_patch) = tool_path_patch(&config_file)? {
844                figment = figment.merge(Serialized::defaults(path_patch));
845            }
846        }
847
848        // Extract merged config file values (no CLI overrides applied yet via Figment)
849        let config_file: ConfigFile = figment.extract().context(crate::error::ConfigExtractSnafu)?;
850
851        // Override the config file values using any CLI args that were specified
852
853        // locked: --unlocked > --locked/--frozen > config > default(true)
854        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        // offline: --offline/--frozen > config > default(false)
861        let offline = if overrides.offline || overrides.lockfile == LockMode::Frozen {
862            true
863        } else {
864            config_file.offline.unwrap_or(false)
865        };
866
867        // The toolchain comes only from the config file here. The CLI `+toolchain` token is
868        // applied later as a `BuildOverride`, not a `ConfigOverride` (`ConfigOverrides` has no
869        // toolchain field), so there is no CLI value to consider at this point.
870        let toolchain = config_file.toolchain;
871
872        // Determine config_dir based on override precedence
873        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        // Determine cache_dir: CLI (app-dir) > config file > strategy
882        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        // Determine bin_dir: CLI (app-dir) > config file > strategy
889        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        // Determine build_dir: CLI (app-dir) > config file > strategy
898        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        // Apply CLI overrides for prebuilt binaries
909        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        // Validate prebuilt binaries configuration
923        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        // Build HTTP config with precedence: CLI > config file > Cargo env vars > defaults
930        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    /// Discover all config file locations in order of precedence.
956    ///
957    /// Returns paths from lowest to highest precedence. Later config files override earlier ones.
958    ///
959    /// The search order is:
960    /// 1. System config: `/etc/cgx.toml` on Unix, Windows equivalent (or override location)
961    /// 2. User config: `$XDG_CONFIG_HOME/cgx/cgx.toml` or platform equivalent (or override
962    ///    location)
963    /// 3. Directory hierarchy: All `cgx.toml` files from filesystem root to current directory
964    fn discover_config_files(cwd: &Path, overrides: &ConfigOverrides) -> Result<Vec<PathBuf>> {
965        let mut config_files = Vec::new();
966
967        // If the user explicitly specified a config file, read ONLY that file
968        if let Some(config_path) = &overrides.config_file {
969            return Ok(vec![config_path.clone()]);
970        }
971
972        // System config (can be overridden)
973        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        // User config (can be overridden via user-config-dir or app-dir)
999        let user_config = if let Some(user_config_dir) = &overrides.user_config_dir {
1000            // Most specific: explicit user config directory
1001            user_config_dir.join("cgx.toml")
1002        } else if let Some(app_dir) = &overrides.app_dir {
1003            // App dir provides a base for config
1004            app_dir.join("config").join("cgx.toml")
1005        } else {
1006            // Default: use platform-specific config directory
1007            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    /// Build [`HttpConfig`] with proper precedence:
1038    /// 1. CLI config overrides (highest priority)
1039    /// 2. Config file values
1040    /// 3. Cargo environment variable fallbacks
1041    /// 4. Defaults (lowest priority)
1042    fn build_http_config(config_file: &HttpConfigFile, overrides: &ConfigOverrides) -> Result<HttpConfig> {
1043        // Determine if CLI args were provided (they override everything)
1044        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        // timeout: CLI > config > CARGO_HTTP_TIMEOUT > default
1049        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        // retries: CLI > config > CARGO_NET_RETRY > default
1071        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        // proxy: CLI > config > CARGO_HTTP_PROXY > None (let reqwest handle system proxies)
1091        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        // backoff settings: config > defaults (no CLI or Cargo env fallback)
1102        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/// Create a fake, isolated config environment for testing, with all of the path config
1116/// settings pointing to a [`tempfile::TempDir`] directory.
1117#[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    /// Apply test-local config directory overrides so config loading cannot read
1143    /// host-level `/etc/cgx.toml` or user-level cgx config on the machine running tests.
1144    ///
1145    /// This keeps tests deterministic on developer systems that actively use cgx.
1146    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        // `default-features` (hyphenated, matching Cargo) is accepted and parsed.
1248        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        // Explicit `default-features = false` is captured.
1258        assert!(!tools.get("no-defaults").unwrap().default_features());
1259
1260        // When the key is absent it defaults to `true`, matching Cargo's default.
1261        assert!(tools.get("with-defaults").unwrap().default_features());
1262
1263        // The legacy underscore spelling is rejected; we accept only the hyphenated Cargo form.
1264        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        // `default-features = false` is rendered with the hyphenated Cargo key, while the default
1306        // `true` is omitted entirely thanks to `skip_serializing_if`.
1307        assert!(rendered.contains("default-features = false"));
1308        assert!(!rendered.contains("default-features = true"));
1309
1310        // The setting survives a round-trip back through the parser.
1311        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        // Even when every tool is a subtable (`[tools.only-detailed]`), the bare `[tools]` header
1515        // is present so the section is always visible and the output is a stable template.
1516        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"), // spellchecker:disable-line
1535            "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); // Default is true per issue #55
1706        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        // NOTE: In `config`, the +nightly toolchain from the command line isn't specified, as this
1724        // is considered to be a build override and not a config option.  Since these tests run
1725        // without any config files, `config.toolchain` is expected to be the default of `None`,
1726        // whiel the CLI-provided toolchain is reflected in the build options.
1727        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        // Other prebuild binary settings should be defaults
1779        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    /// Test the config loading logic that traverses up a directory hierarchy looking for config
1849    /// files.
1850    ///
1851    /// `testdata/configs` contains test config files constructed specifically to facilitate these
1852    /// tests
1853    mod hierarchy_tests {
1854        use assert_matches::assert_matches;
1855
1856        use super::*;
1857        use crate::builder::BuildOptions;
1858
1859        /// Test loading config from a 3-level hierarchy (root -> work -> project1).
1860        ///
1861        /// Verifies that config files are merged in order of precedence, with closer files
1862        /// overriding values from parent directories. The `resolve_cache_timeout` should be 3m
1863        /// (from project1), tools should include entries from all 3 levels (5 total), and aliases
1864        /// should show the `dummytool` override from project1.
1865        #[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 loading config from a parallel 3-level hierarchy (root -> work -> project2).
1888        ///
1889        /// Similar to project1, but verifies that sibling project directories maintain
1890        /// independent configurations. The `resolve_cache_timeout` should be 5m (from project2),
1891        /// tools should include `project2_tool` instead of `project1_tool` (5 total), and the
1892        /// `dummytool` alias should override to "project2".
1893        #[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 loading config from a 2-level hierarchy (root -> work).
1916        ///
1917        /// Verifies config merging at an intermediate level in the hierarchy. The
1918        /// `resolve_cache_timeout` should be 2m (from work), tools should include entries from
1919        /// both root and work (4 total), and the `dummytool` alias should override to "work".
1920        #[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 loading config from the root level only.
1942        ///
1943        /// Establishes the baseline configuration from the root config file. The
1944        /// `resolve_cache_timeout` should be 1m (from root), and only root-level tools and aliases
1945        /// should be present (3 tools, 3 aliases including dummytool="root").
1946        #[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 that specifying `--config-file` bypasses hierarchy traversal.
1967        ///
1968        /// When an explicit config file is provided via CLI, ONLY that file is read without
1969        /// walking up the directory tree. This test uses a non-standard filename to verify
1970        /// it's the explicit path (not discovery) that loads the config. Should have only 1 tool
1971        /// and 1 alias from the specified file, with timeout=6m.
1972        #[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 that detailed tool configurations are preserved during hierarchy merging.
1994        ///
1995        /// Verifies that tools specified with detailed configs (version, features, etc.) maintain
1996        /// their structure when merged across the hierarchy. The taplo-cli tool from root should
1997        /// retain its version="1.11.0" and features=["schema"] specification.
1998        #[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 that CLI arguments have the highest precedence over config files.
2017        ///
2018        /// Command-line flags should override any values set in config files, regardless of
2019        /// where those config files appear in the hierarchy. This verifies that --offline,
2020        /// --locked, and +toolchain flags take precedence over the merged config.
2021        #[test]
2022        fn test_cli_args_override_config_files() {
2023            let test_case = crate::testdata::ConfigTestCase::hierarchy_project1();
2024
2025            // NOTE: `+stable` is the toolchain specification; this doesn't go in config overrides
2026            // but in build overrides.
2027            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            // `+stable` doesn't override the config (because we don't consider the toolchain a
2041            // config override), but it overrides the build options
2042            assert_eq!(config.toolchain, None);
2043            assert_eq!(build_options.toolchain, Some("stable".to_string()));
2044        }
2045
2046        /// Test that --config-file reads only the specified file.
2047        ///
2048        /// When --config-file is specified, only that single config file should be loaded,
2049        /// bypassing all config discovery (system, user, and hierarchy configs).
2050        #[test]
2051        fn test_config_file_reads_only_specified_file() {
2052            // The hierarchy has configs with resolve_cache_timeout set to various values:
2053            // root=1m, work=2m, project1=3m
2054            let hierarchy_dir = crate::testdata::ConfigTestCase::hierarchy_project1();
2055
2056            // The explicit config has a different timeout (6m)
2057            let explicit_config = crate::testdata::ConfigTestCase::explicit_non_standard_name();
2058
2059            // Load config from project1 directory but with --config-file pointing to explicit config
2060            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            // Should have the explicit config's timeout (6m), not any from the hierarchy (1m/2m/3m)
2066            assert_eq!(config.resolve_cache_timeout, Duration::from_secs(6 * 60));
2067
2068            // Should have only the tool from explicit config, not from hierarchy
2069            assert!(config.tools.contains_key("project1_tool"));
2070            assert_eq!(config.tools.len(), 1);
2071
2072            // Should have only the alias from explicit config
2073            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 that [`discover_config_files`] returns only the explicit file when --config-file is
2087        /// set.
2088        ///
2089        /// This directly tests the discovery logic to ensure hierarchy configs are not included.
2090        #[test]
2091        fn test_discover_only_explicit_file() {
2092            // RAII guard to ensure user config cleanup happens even if test panics
2093            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            // Create a hierarchy of config files
2110            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            // Create an explicit config elsewhere
2119            let explicit_config = temp_dir.path().join("explicit.toml");
2120            fs::write(&explicit_config, "resolve_cache_timeout = \"3m\"").unwrap();
2121
2122            // Create a user config to trigger the bug (if it doesn't already exist)
2123            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            // Guard ensures cleanup even if test panics
2130            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            // Test with --config-file
2144            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            // Should contain ONLY the explicit config file (no system, user, or hierarchy configs)
2150            // This will FAIL if the bug exists, showing [user_config, explicit_config]
2151            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 that hierarchy configs are discovered when --config-file is not set.
2162        #[test]
2163        fn test_discover_hierarchy_without_explicit() {
2164            let temp_dir = tempfile::tempdir().unwrap();
2165            let cwd = temp_dir.path();
2166
2167            // Create a hierarchy of config files
2168            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            // Should contain both hierarchy configs (and possibly system/user if they exist)
2180            // We check that at least our two configs are present
2181            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                // Also set user_config_dir to ensure isolation (no real user config is loaded)
2212                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                // Create system config with 10m timeout
2228                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                // Create user config with 20m timeout
2237                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                // User config should override system config
2254                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                // All directories should be under app_dir
2335                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                // Create app_dir with config
2368                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                // Create user_config_dir with different config
2374                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                // user_config_dir should override app_dir for config location
2392                assert_eq!(config.resolve_cache_timeout, Duration::from_secs(11 * 60));
2393                assert_eq!(config.config_dir, user_config_dir);
2394
2395                // But cache/bins/build should still come from app_dir
2396                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                // System config
2410                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                // App dir with config
2419                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                // User config dir
2425                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                // Should have merged tools from all configs
2445                assert!(config.tools.contains_key("system_tool"));
2446                assert!(config.tools.contains_key("user_tool"));
2447                assert_eq!(config.tools.len(), 2);
2448
2449                // User config should override alias
2450                assert_eq!(config.aliases.get("dummytool"), Some(&"user".to_string()));
2451
2452                // Config dir from user_config_dir
2453                assert_eq!(config.config_dir, user_config_dir);
2454
2455                // Other dirs from app_dir
2456                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                // App dir
2466                let app_dir = temp_dir.path().join("app");
2467
2468                // Create hierarchy with configs
2469                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                // Should have tools from both hierarchy configs
2483                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                // App dir
2493                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                // Config file with explicit settings that should be overridden
2498                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                // CLI --app-dir should win over config file settings
2517                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                // Config file with explicit path settings
2527                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                // No --app-dir specified
2541                config_overrides.config_file = Some(config_file);
2542
2543                let config = Config::load_from_dir(&cwd, &config_overrides).unwrap();
2544
2545                // Config file paths should be used when --app-dir is not specified
2546                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        /// Verifies hierarchy merge behavior where a child config overrides timeout
2787        /// while inheriting retries from its parent `[http]` section.
2788        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            // Timeout comes from the child, overriding the parent, but since retries wasn't
2821            // specified in the child, it should be inherited from the parent config
2822            assert_eq!(config.http.timeout, Duration::from_secs(45));
2823            assert_eq!(config.http.retries, 3);
2824        }
2825
2826        #[test]
2827        /// Verifies hierarchy merge behavior where a child config explicitly
2828        /// overrides parent timeout and retries fields in `[http]`.
2829        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        /// Verifies `CARGO_HTTP_TIMEOUT` is used when neither CLI nor config file sets timeout.
2884        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        /// Verifies `CARGO_NET_RETRY` is used when neither CLI nor config file sets retries.
2897        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        /// Verifies `CARGO_HTTP_PROXY` is used when neither CLI nor config file sets proxy.
2910        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        /// Verifies CLI HTTP flags take precedence over Cargo HTTP environment variables.
2927        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        /// Verifies config file `[http]` values take precedence over Cargo HTTP env variables.
2955        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        /// Verifies invalid `CARGO_HTTP_TIMEOUT` falls back to the built-in default timeout.
2976        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        /// Verifies invalid `CARGO_NET_RETRY` falls back to the built-in default retries value.
2989        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            // Ensure isolation from developer's real cgx config on their system.
3146            // Without these overrides, this test would load ~/.config/cgx/cgx.toml if it exists,
3147            // causing the test to fail with config values from the developer's actual config.
3148            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); // Default is true per issue #55
3156            assert_eq!(config.toolchain, None);
3157            assert_eq!(config.tools.len(), 0);
3158            assert_eq!(config.aliases.len(), 0);
3159        }
3160    }
3161}