use crate::{Result, cli::CliArgs};
use etcetera::{AppStrategy, AppStrategyArgs, choose_app_strategy};
use serde::{Deserialize, Serialize};
use snafu::ResultExt;
use std::{
collections::HashMap,
path::{Path, PathBuf},
time::Duration,
};
use strum::{Display, EnumIter, EnumString, IntoStaticStr, VariantNames};
const DEFAULT_RESOLVE_CACHE_TIMEOUT: Duration = Duration::from_secs(60 * 60);
const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_HTTP_RETRIES: usize = 2;
const DEFAULT_HTTP_BACKOFF_BASE: Duration = Duration::from_millis(500);
const DEFAULT_HTTP_BACKOFF_MAX: Duration = Duration::from_secs(5);
#[derive(
Default, Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, EnumString, Display, VariantNames,
)]
#[strum(serialize_all = "kebab-case")]
#[serde(rename_all = "kebab-case")]
pub enum UsePrebuiltBinaries {
#[default]
Auto,
Always,
Never,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
EnumString,
Display,
IntoStaticStr,
EnumIter,
VariantNames,
)]
#[strum(serialize_all = "kebab-case")]
#[serde(rename_all = "kebab-case")]
pub enum BinaryProvider {
Binstall,
GithubReleases,
GitlabReleases,
Quickinstall,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct PrebuiltBinariesConfig {
pub use_prebuilt_binaries: UsePrebuiltBinaries,
pub binary_providers: Vec<BinaryProvider>,
pub verify_checksums: bool,
pub verify_signatures: bool,
}
impl Default for PrebuiltBinariesConfig {
fn default() -> Self {
Self {
use_prebuilt_binaries: UsePrebuiltBinaries::Auto,
binary_providers: vec![
BinaryProvider::Binstall,
BinaryProvider::GithubReleases,
BinaryProvider::GitlabReleases,
BinaryProvider::Quickinstall,
],
verify_checksums: true,
verify_signatures: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct HttpConfig {
#[serde(with = "humantime_serde")]
pub timeout: Duration,
pub retries: usize,
#[serde(with = "humantime_serde")]
pub backoff_base: Duration,
#[serde(with = "humantime_serde")]
pub backoff_max: Duration,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy: Option<String>,
}
impl Default for HttpConfig {
fn default() -> Self {
Self {
timeout: DEFAULT_HTTP_TIMEOUT,
retries: DEFAULT_HTTP_RETRIES,
backoff_base: DEFAULT_HTTP_BACKOFF_BASE,
backoff_max: DEFAULT_HTTP_BACKOFF_MAX,
proxy: None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct HttpConfigFile {
#[serde(default, with = "humantime_serde::option")]
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<Duration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retries: Option<usize>,
#[serde(default, with = "humantime_serde::option")]
#[serde(skip_serializing_if = "Option::is_none")]
pub backoff_base: Option<Duration>,
#[serde(default, with = "humantime_serde::option")]
#[serde(skip_serializing_if = "Option::is_none")]
pub backoff_max: Option<Duration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields, untagged)]
pub enum ToolConfig {
Version(String),
Detailed {
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
features: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
registry: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
git: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<PathBuf>,
},
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(deserialize_with = "deserialize_optional_expanded_path")]
pub bin_dir: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(deserialize_with = "deserialize_optional_expanded_path")]
pub build_dir: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(deserialize_with = "deserialize_optional_expanded_path")]
pub cache_dir: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locked: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offline: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(with = "humantime_serde")]
pub resolve_cache_timeout: Option<Duration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub toolchain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_registry: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prebuilt_binaries: Option<PrebuiltBinariesConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http: Option<HttpConfigFile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<HashMap<String, ToolConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aliases: Option<HashMap<String, String>>,
}
impl ConfigFile {
pub fn base_config() -> Self {
Self {
bin_dir: None,
build_dir: None,
cache_dir: None,
locked: Some(true),
log_level: None,
offline: Some(false),
resolve_cache_timeout: Some(DEFAULT_RESOLVE_CACHE_TIMEOUT),
toolchain: None,
default_registry: None,
prebuilt_binaries: Some(PrebuiltBinariesConfig::default()),
http: None,
tools: None,
aliases: None,
}
}
}
fn deserialize_optional_expanded_path<'de, D>(
deserializer: D,
) -> std::result::Result<Option<PathBuf>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt_string: Option<String> = Option::deserialize(deserializer)?;
match opt_string {
None => Ok(None),
Some(s) => {
let expanded = shellexpand::tilde(&s);
Ok(Some(PathBuf::from(expanded.as_ref())))
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
#[allow(dead_code)]
pub config_dir: PathBuf,
pub cache_dir: PathBuf,
pub bin_dir: PathBuf,
pub build_dir: PathBuf,
pub resolve_cache_timeout: Duration,
pub offline: bool,
pub locked: bool,
pub refresh: bool,
pub toolchain: Option<String>,
pub log_level: Option<String>,
pub default_registry: Option<String>,
pub prebuilt_binaries: PrebuiltBinariesConfig,
pub http: HttpConfig,
pub tools: HashMap<String, ToolConfig>,
pub aliases: HashMap<String, String>,
}
impl Default for Config {
fn default() -> Self {
Self {
config_dir: PathBuf::default(),
cache_dir: PathBuf::default(),
bin_dir: PathBuf::default(),
build_dir: PathBuf::default(),
resolve_cache_timeout: Duration::from_secs(3600),
offline: false,
locked: true,
refresh: false,
toolchain: None,
log_level: None,
default_registry: None,
prebuilt_binaries: PrebuiltBinariesConfig::default(),
http: HttpConfig::default(),
tools: HashMap::default(),
aliases: HashMap::default(),
}
}
}
impl Config {
pub fn load(args: &CliArgs) -> Result<Self> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Self::load_from_dir(&cwd, args)
}
pub fn load_from_dir(cwd: &Path, args: &CliArgs) -> Result<Self> {
use figment::{
Figment,
providers::{Format, Serialized, Toml},
};
let strategy = Self::get_user_dirs()?;
let mut figment = Figment::new().merge(Serialized::defaults(ConfigFile::base_config()));
for config_file in Self::discover_config_files(cwd, args)? {
figment = figment.merge(Toml::file(config_file));
}
let config_file: ConfigFile = figment.extract().context(crate::error::ConfigExtractSnafu)?;
let locked = if args.unlocked {
false
} else if args.locked || args.frozen {
true
} else {
config_file.locked.unwrap_or(true)
};
let offline = if args.offline || args.frozen {
true
} else {
config_file.offline.unwrap_or(false)
};
let toolchain = args.toolchain.clone().or(config_file.toolchain);
let config_dir = if let Some(user_config_dir) = &args.user_config_dir {
user_config_dir.clone()
} else if let Some(app_dir) = &args.app_dir {
app_dir.join("config")
} else {
strategy.config_dir()
};
let cache_dir = if let Some(app_dir) = &args.app_dir {
app_dir.join("cache")
} else {
config_file.cache_dir.unwrap_or_else(|| strategy.cache_dir())
};
let bin_dir = if let Some(app_dir) = &args.app_dir {
app_dir.join("bins")
} else {
config_file
.bin_dir
.unwrap_or_else(|| strategy.in_data_dir("bins"))
};
let build_dir = if let Some(app_dir) = &args.app_dir {
app_dir.join("build")
} else {
config_file
.build_dir
.unwrap_or_else(|| strategy.in_data_dir("build"))
};
let mut prebuilt_binaries = config_file.prebuilt_binaries.unwrap_or_default();
if let Some(mode) = args.prebuilt_binary {
prebuilt_binaries.use_prebuilt_binaries = mode;
}
if let Some(ref providers) = args.prebuilt_binary_sources {
prebuilt_binaries.binary_providers = providers.clone();
}
if args.prebuilt_binary_no_verify_checksums {
prebuilt_binaries.verify_checksums = false;
}
if args.prebuilt_binary_no_verify_signatures {
prebuilt_binaries.verify_signatures = false;
}
if prebuilt_binaries.binary_providers.is_empty()
&& prebuilt_binaries.use_prebuilt_binaries != UsePrebuiltBinaries::Never
{
return crate::error::NoProvidersConfiguredSnafu.fail();
}
let http_config_file = config_file.http.unwrap_or_default();
let http = Self::build_http_config(&http_config_file, args)?;
Ok(Self {
config_dir,
cache_dir,
bin_dir,
build_dir,
resolve_cache_timeout: config_file
.resolve_cache_timeout
.unwrap_or(DEFAULT_RESOLVE_CACHE_TIMEOUT),
offline,
locked,
refresh: args.refresh,
toolchain,
log_level: config_file.log_level,
default_registry: config_file.default_registry,
prebuilt_binaries,
http,
tools: config_file.tools.unwrap_or_default(),
aliases: config_file.aliases.unwrap_or_default(),
})
}
fn discover_config_files(cwd: &Path, args: &CliArgs) -> Result<Vec<PathBuf>> {
let mut config_files = Vec::new();
if let Some(config_path) = &args.config_file {
return Ok(vec![config_path.clone()]);
}
if let Some(system_config_dir) = &args.system_config_dir {
let system_config = system_config_dir.join("cgx.toml");
if system_config.exists() {
config_files.push(system_config);
}
} else {
#[cfg(unix)]
{
let system_config = PathBuf::from("/etc/cgx.toml");
if system_config.exists() {
config_files.push(system_config);
}
}
#[cfg(windows)]
{
if let Some(program_data) = std::env::var_os("ProgramData") {
let system_config = PathBuf::from(program_data).join("cgx").join("cgx.toml");
if system_config.exists() {
config_files.push(system_config);
}
}
}
}
let user_config = if let Some(user_config_dir) = &args.user_config_dir {
user_config_dir.join("cgx.toml")
} else if let Some(app_dir) = &args.app_dir {
app_dir.join("config").join("cgx.toml")
} else {
let strategy = Self::get_user_dirs()?;
strategy.config_dir().join("cgx.toml")
};
if user_config.exists() {
config_files.push(user_config);
}
let mut ancestors: Vec<PathBuf> = cwd.ancestors().map(|p| p.to_path_buf()).collect();
ancestors.reverse();
for ancestor in ancestors {
let config_file = ancestor.join("cgx.toml");
if config_file.exists() {
config_files.push(config_file);
}
}
Ok(config_files)
}
fn get_user_dirs() -> Result<impl AppStrategy> {
choose_app_strategy(AppStrategyArgs {
top_level_domain: "org".to_string(),
author: "anelson".to_string(),
app_name: "cgx".to_string(),
})
.context(crate::error::EtceteraSnafu)
}
fn build_http_config(config_file: &HttpConfigFile, args: &CliArgs) -> Result<HttpConfig> {
let cli_timeout = args.http_timeout.as_ref();
let cli_retries = args.http_retries;
let cli_proxy = args.http_proxy.as_ref();
let timeout = if let Some(timeout_str) = cli_timeout {
humantime::parse_duration(timeout_str).context(crate::error::InvalidHttpTimeoutSnafu {
value: timeout_str.clone(),
})?
} else if let Some(config_timeout) = config_file.timeout {
config_timeout
} else if let Ok(cargo_timeout) = std::env::var("CARGO_HTTP_TIMEOUT") {
if let Ok(secs) = cargo_timeout.parse::<u64>() {
Duration::from_secs(secs)
} else {
tracing::warn!(
"Invalid CARGO_HTTP_TIMEOUT value '{}', falling back to default {:?}.",
cargo_timeout,
DEFAULT_HTTP_TIMEOUT
);
DEFAULT_HTTP_TIMEOUT
}
} else {
DEFAULT_HTTP_TIMEOUT
};
let retries = if let Some(cli_retries) = cli_retries {
cli_retries
} else if let Some(config_retries) = config_file.retries {
config_retries
} else if let Ok(cargo_retry) = std::env::var("CARGO_NET_RETRY") {
if let Ok(retries) = cargo_retry.parse::<usize>() {
retries
} else {
tracing::warn!(
"Invalid CARGO_NET_RETRY value '{}', falling back to default {}.",
cargo_retry,
DEFAULT_HTTP_RETRIES
);
DEFAULT_HTTP_RETRIES
}
} else {
DEFAULT_HTTP_RETRIES
};
let proxy = if let Some(p) = cli_proxy {
Some(p.clone())
} else if config_file.proxy.is_some() {
config_file.proxy.clone()
} else if let Ok(cargo_proxy) = std::env::var("CARGO_HTTP_PROXY") {
Some(cargo_proxy)
} else {
None
};
let backoff_base = config_file.backoff_base.unwrap_or(DEFAULT_HTTP_BACKOFF_BASE);
let backoff_max = config_file.backoff_max.unwrap_or(DEFAULT_HTTP_BACKOFF_MAX);
Ok(HttpConfig {
timeout,
retries,
backoff_base,
backoff_max,
proxy,
})
}
}
#[cfg(test)]
pub(crate) fn create_test_env() -> (tempfile::TempDir, Config) {
let temp_dir = tempfile::tempdir().unwrap();
let config = Config {
config_dir: temp_dir.path().join("config"),
cache_dir: temp_dir.path().join("cache"),
bin_dir: temp_dir.path().join("bins"),
build_dir: temp_dir.path().join("build"),
resolve_cache_timeout: Duration::from_secs(3600),
locked: true,
..Default::default()
};
(temp_dir, config)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn with_isolated_global_config(mut args: CliArgs, root: &Path) -> CliArgs {
args.system_config_dir = Some(root.join("system"));
args.user_config_dir = Some(root.join("user"));
args
}
#[test]
fn test_deserialize_basic_config() {
let toml_content = r#"
bin_dir = "/usr/local/bin"
cache_dir = "/tmp/cache"
offline = true
locked = false
"#;
let config: ConfigFile = toml::from_str(toml_content).unwrap();
assert_eq!(config.bin_dir, Some(PathBuf::from("/usr/local/bin")));
assert_eq!(config.cache_dir, Some(PathBuf::from("/tmp/cache")));
assert_eq!(config.offline, Some(true));
assert_eq!(config.locked, Some(false));
}
#[test]
fn test_deserialize_duration() {
let toml_content = r#"
resolve_cache_timeout = "2h"
"#;
let config: ConfigFile = toml::from_str(toml_content).unwrap();
assert_eq!(
config.resolve_cache_timeout,
Some(Duration::from_secs(2 * 60 * 60))
);
}
#[test]
fn test_deserialize_tilde_expansion() {
let toml_content = r#"
bin_dir = "~/.local/bin"
"#;
let config: ConfigFile = toml::from_str(toml_content).unwrap();
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap();
let expected = PathBuf::from(home).join(".local/bin");
assert_eq!(config.bin_dir, Some(expected));
}
#[test]
fn test_deserialize_binary_providers() {
let toml_content = r#"
[prebuilt_binaries]
binary_providers = ["github-releases", "quickinstall"]
"#;
let config: ConfigFile = toml::from_str(toml_content).unwrap();
assert_eq!(
config.prebuilt_binaries.unwrap().binary_providers,
vec![BinaryProvider::GithubReleases, BinaryProvider::Quickinstall,]
);
}
#[test]
fn test_deserialize_tools_simple() {
let toml_content = r#"
[tools]
ripgrep = "14.0"
"#;
let config: ConfigFile = toml::from_str(toml_content).unwrap();
let tools = config.tools.unwrap();
assert_eq!(
tools.get("ripgrep"),
Some(&ToolConfig::Version("14.0".to_string()))
);
}
#[test]
fn test_deserialize_tools_detailed() {
let toml_content = r#"
[tools]
taplo-cli = { version = "1.11.0", features = ["schema"] }
"#;
let config: ConfigFile = toml::from_str(toml_content).unwrap();
let tools = config.tools.unwrap();
match tools.get("taplo-cli") {
Some(ToolConfig::Detailed {
version, features, ..
}) => {
assert_eq!(*version, Some("1.11.0".to_string()));
assert_eq!(*features, Some(vec!["schema".to_string()]));
}
_ => panic!("Expected Detailed tool config"),
}
}
#[test]
fn test_deserialize_aliases() {
let toml_content = r#"
[aliases]
rg = "ripgrep"
taplo = "taplo-cli"
"#;
let config: ConfigFile = toml::from_str(toml_content).unwrap();
let aliases = config.aliases.unwrap();
assert_eq!(aliases.get("rg"), Some(&"ripgrep".to_string()));
assert_eq!(aliases.get("taplo"), Some(&"taplo-cli".to_string()));
}
#[test]
fn test_config_defaults() {
let args = CliArgs::parse_from_test_args(["test-crate"]);
let config = Config::load(&args).unwrap();
assert!(!config.offline);
assert!(config.locked); assert_eq!(config.toolchain, None);
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(60 * 60));
}
#[test]
fn test_cli_overrides() {
let args = CliArgs::parse_from_test_args(["+nightly", "--offline", "--locked", "test-crate"]);
let config = Config::load(&args).unwrap();
assert!(config.offline);
assert!(config.locked);
assert_eq!(config.toolchain, Some("nightly".to_string()));
}
#[test]
fn test_frozen_implies_locked_and_offline() {
let args = CliArgs::parse_from_test_args(["--frozen", "test-crate"]);
let config = Config::load(&args).unwrap();
assert!(config.offline);
assert!(config.locked);
}
#[test]
fn test_full_config_example() {
let toml_content = r#"
bin_dir = "~/.local/bin"
build_dir = "~/.local/build"
cache_dir = "~/.cache/cgx"
locked = true
log_level = "info"
offline = false
resolve_cache_timeout = "1h"
toolchain = "stable"
default_registry = "my-registry"
[prebuilt_binaries]
binary_providers = ["github-releases", "gitlab-releases", "quickinstall"]
[tools]
ripgrep = "*"
taplo-cli = { version = "1.11.0", features = ["schema"] }
[aliases]
rg = "ripgrep"
taplo = "taplo-cli"
"#;
let config: ConfigFile = toml::from_str(toml_content).unwrap();
assert_eq!(config.log_level, Some("info".to_string()));
assert_eq!(config.toolchain, Some("stable".to_string()));
assert_eq!(config.default_registry, Some("my-registry".to_string()));
assert_eq!(config.locked, Some(true));
assert_eq!(config.offline, Some(false));
assert_eq!(config.resolve_cache_timeout, Some(Duration::from_secs(60 * 60)));
let prebuilt_binaries = config.prebuilt_binaries.unwrap();
assert_eq!(prebuilt_binaries.binary_providers.len(), 3);
assert_eq!(prebuilt_binaries.use_prebuilt_binaries, UsePrebuiltBinaries::Auto);
assert!(prebuilt_binaries.verify_checksums);
assert!(prebuilt_binaries.verify_signatures);
let tools = config.tools.unwrap();
assert_eq!(tools.len(), 2);
let aliases = config.aliases.unwrap();
assert_eq!(aliases.len(), 2);
}
mod prebuilt_validation_tests {
use super::*;
use assert_matches::assert_matches;
use std::io::Write;
fn create_temp_config(toml_content: &str) -> tempfile::TempDir {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("cgx.toml");
let mut file = std::fs::File::create(&config_path).unwrap();
file.write_all(toml_content.as_bytes()).unwrap();
temp_dir
}
#[test]
fn test_empty_providers_with_auto_fails() {
let toml_content = r#"
[prebuilt_binaries]
use_prebuilt_binaries = "auto"
binary_providers = []
"#;
let temp_dir = create_temp_config(toml_content);
let args = CliArgs::parse_from_test_args(["test-crate"]);
let result = Config::load_from_dir(temp_dir.path(), &args);
assert_matches!(result, Err(crate::error::Error::NoProvidersConfigured));
}
#[test]
fn test_empty_providers_with_always_fails() {
let toml_content = r#"
[prebuilt_binaries]
use_prebuilt_binaries = "always"
binary_providers = []
"#;
let temp_dir = create_temp_config(toml_content);
let args = CliArgs::parse_from_test_args(["test-crate"]);
let result = Config::load_from_dir(temp_dir.path(), &args);
assert_matches!(result, Err(crate::error::Error::NoProvidersConfigured));
}
#[test]
fn test_empty_providers_with_never_ok() {
let toml_content = r#"
[prebuilt_binaries]
use_prebuilt_binaries = "never"
binary_providers = []
"#;
let temp_dir = create_temp_config(toml_content);
let args = CliArgs::parse_from_test_args(["test-crate"]);
let result = Config::load_from_dir(temp_dir.path(), &args);
assert!(result.is_ok(), "Empty providers with 'never' mode should succeed");
}
}
mod hierarchy_tests {
use super::*;
use assert_matches::assert_matches;
#[test]
fn test_config_hierarchy_project1() {
let test_case = crate::testdata::ConfigTestCase::hierarchy_project1();
let args = CliArgs::parse_from_test_args(["test-crate"]);
let config = Config::load_from_dir(test_case.path(), &args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(3 * 60));
assert!(config.tools.contains_key("ripgrep"));
assert!(config.tools.contains_key("root_tool"));
assert!(config.tools.contains_key("taplo-cli"));
assert!(config.tools.contains_key("work_tool"));
assert!(config.tools.contains_key("project1_tool"));
assert_eq!(config.tools.len(), 5);
assert_eq!(config.aliases.get("dummytool"), Some(&"project1".to_string()));
assert_eq!(config.aliases.get("rg"), Some(&"ripgrep".to_string()));
assert_eq!(config.aliases.get("taplo"), Some(&"taplo-cli".to_string()));
assert_eq!(config.aliases.len(), 3);
}
#[test]
fn test_config_hierarchy_project2() {
let test_case = crate::testdata::ConfigTestCase::hierarchy_project2();
let args = CliArgs::parse_from_test_args(["test-crate"]);
let config = Config::load_from_dir(test_case.path(), &args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(5 * 60));
assert!(config.tools.contains_key("ripgrep"));
assert!(config.tools.contains_key("root_tool"));
assert!(config.tools.contains_key("taplo-cli"));
assert!(config.tools.contains_key("work_tool"));
assert!(config.tools.contains_key("project2_tool"));
assert_eq!(config.tools.len(), 5);
assert_eq!(config.aliases.get("dummytool"), Some(&"project2".to_string()));
assert_eq!(config.aliases.get("rg"), Some(&"ripgrep".to_string()));
assert_eq!(config.aliases.get("taplo"), Some(&"taplo-cli".to_string()));
assert_eq!(config.aliases.len(), 3);
}
#[test]
fn test_config_hierarchy_work() {
let test_case = crate::testdata::ConfigTestCase::hierarchy_work();
let args = CliArgs::parse_from_test_args(["test-crate"]);
let config = Config::load_from_dir(test_case.path(), &args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(2 * 60));
assert!(config.tools.contains_key("ripgrep"));
assert!(config.tools.contains_key("root_tool"));
assert!(config.tools.contains_key("taplo-cli"));
assert!(config.tools.contains_key("work_tool"));
assert_eq!(config.tools.len(), 4);
assert_eq!(config.aliases.get("dummytool"), Some(&"work".to_string()));
assert_eq!(config.aliases.get("rg"), Some(&"ripgrep".to_string()));
assert_eq!(config.aliases.get("taplo"), Some(&"taplo-cli".to_string()));
assert_eq!(config.aliases.len(), 3);
}
#[test]
fn test_config_hierarchy_root() {
let test_case = crate::testdata::ConfigTestCase::hierarchy_root();
let args = CliArgs::parse_from_test_args(["test-crate"]);
let config = Config::load_from_dir(test_case.path(), &args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(60));
assert!(config.tools.contains_key("ripgrep"));
assert!(config.tools.contains_key("root_tool"));
assert!(config.tools.contains_key("taplo-cli"));
assert_eq!(config.tools.len(), 3);
assert_eq!(config.aliases.get("dummytool"), Some(&"root".to_string()));
assert_eq!(config.aliases.get("rg"), Some(&"ripgrep".to_string()));
assert_eq!(config.aliases.get("taplo"), Some(&"taplo-cli".to_string()));
assert_eq!(config.aliases.len(), 3);
}
#[test]
fn test_explicit_config_file() {
let test_case = crate::testdata::ConfigTestCase::explicit_non_standard_name();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.config_file = Some(test_case.path().to_path_buf());
let config = Config::load(&args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(6 * 60));
assert!(config.tools.contains_key("project1_tool"));
assert_eq!(config.tools.len(), 1);
assert_eq!(
config.aliases.get("dummytool"),
Some(&"not_called_cgx_project1".to_string())
);
assert_eq!(config.aliases.len(), 1);
}
#[test]
fn test_tools_detailed_config_preserved() {
let test_case = crate::testdata::ConfigTestCase::hierarchy_root();
let args = CliArgs::parse_from_test_args(["test-crate"]);
let config = Config::load_from_dir(test_case.path(), &args).unwrap();
let taplo_tool = config.tools.get("taplo-cli").unwrap();
assert_matches!(
taplo_tool,
ToolConfig::Detailed {
version: Some(v),
features: Some(f),
..
} if v == "1.11.0" && f == &vec!["schema".to_string()]
);
}
#[test]
fn test_cli_args_override_config_files() {
let test_case = crate::testdata::ConfigTestCase::hierarchy_project1();
let args = CliArgs::parse_from_test_args(["+stable", "--offline", "--locked", "test-crate"]);
let config = Config::load_from_dir(test_case.path(), &args).unwrap();
assert!(config.offline);
assert!(config.locked);
assert_eq!(config.toolchain, Some("stable".to_string()));
}
#[test]
fn test_config_file_reads_only_specified_file() {
let hierarchy_dir = crate::testdata::ConfigTestCase::hierarchy_project1();
let explicit_config = crate::testdata::ConfigTestCase::explicit_non_standard_name();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.config_file = Some(explicit_config.path().to_path_buf());
let config = Config::load_from_dir(hierarchy_dir.path(), &args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(6 * 60));
assert!(config.tools.contains_key("project1_tool"));
assert_eq!(config.tools.len(), 1);
assert_eq!(
config.aliases.get("dummytool"),
Some(&"not_called_cgx_project1".to_string())
);
assert_eq!(config.aliases.len(), 1);
}
}
mod config_file_discovery_tests {
use super::*;
#[test]
fn test_discover_only_explicit_file() {
use std::fs;
struct UserConfigGuard {
path: PathBuf,
should_delete: bool,
}
impl Drop for UserConfigGuard {
fn drop(&mut self) {
if self.should_delete {
fs::remove_file(&self.path).ok();
}
}
}
let temp_dir = tempfile::tempdir().unwrap();
let cwd = temp_dir.path();
let root_config = cwd.join("cgx.toml");
fs::write(&root_config, "resolve_cache_timeout = \"1m\"").unwrap();
let sub_dir = cwd.join("subdir");
fs::create_dir(&sub_dir).unwrap();
let sub_config = sub_dir.join("cgx.toml");
fs::write(&sub_config, "resolve_cache_timeout = \"2m\"").unwrap();
let explicit_config = temp_dir.path().join("explicit.toml");
fs::write(&explicit_config, "resolve_cache_timeout = \"3m\"").unwrap();
let strategy = Config::get_user_dirs().unwrap();
let user_config_dir = strategy.config_dir();
fs::create_dir_all(&user_config_dir).ok();
let user_config_path = user_config_dir.join("cgx.toml");
let user_config_existed = user_config_path.exists();
let _guard = if !user_config_existed {
fs::write(&user_config_path, "resolve_cache_timeout = \"99m\"").unwrap();
UserConfigGuard {
path: user_config_path,
should_delete: true,
}
} else {
UserConfigGuard {
path: user_config_path,
should_delete: false,
}
};
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.config_file = Some(explicit_config.clone());
let discovered = Config::discover_config_files(&sub_dir, &args).unwrap();
assert_eq!(
discovered.len(),
1,
"Expected only 1 config file, got {}: {:?}",
discovered.len(),
discovered
);
assert_eq!(discovered[0], explicit_config);
}
#[test]
fn test_discover_hierarchy_without_explicit() {
use std::fs;
let temp_dir = tempfile::tempdir().unwrap();
let cwd = temp_dir.path();
let root_config = cwd.join("cgx.toml");
fs::write(&root_config, "resolve_cache_timeout = \"1m\"").unwrap();
let sub_dir = cwd.join("subdir");
fs::create_dir(&sub_dir).unwrap();
let sub_config = sub_dir.join("cgx.toml");
fs::write(&sub_config, "resolve_cache_timeout = \"2m\"").unwrap();
let args = CliArgs::parse_from_test_args(["test-crate"]);
let discovered = Config::discover_config_files(&sub_dir, &args).unwrap();
assert!(
discovered.contains(&root_config),
"Root config should be discovered"
);
assert!(
discovered.contains(&sub_config),
"Sub config should be discovered"
);
}
}
mod override_tests {
use super::*;
use std::fs;
mod system_config_dir_tests {
use super::*;
#[test]
fn test_system_config_dir_cli_arg() {
let temp_dir = tempfile::tempdir().unwrap();
let system_config_dir = temp_dir.path().join("system");
fs::create_dir_all(&system_config_dir).unwrap();
let system_config = system_config_dir.join("cgx.toml");
fs::write(&system_config, "resolve_cache_timeout = \"5m\"").unwrap();
let cwd = temp_dir.path().join("work");
fs::create_dir_all(&cwd).unwrap();
let user_config_dir = temp_dir.path().join("user");
fs::create_dir_all(&user_config_dir).unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.system_config_dir = Some(system_config_dir);
args.user_config_dir = Some(user_config_dir);
let config = Config::load_from_dir(&cwd, &args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(5 * 60));
}
#[test]
fn test_system_config_dir_vs_user_config() {
let temp_dir = tempfile::tempdir().unwrap();
let system_config_dir = temp_dir.path().join("system");
fs::create_dir_all(&system_config_dir).unwrap();
fs::write(
system_config_dir.join("cgx.toml"),
"resolve_cache_timeout = \"10m\"",
)
.unwrap();
let user_config_dir = temp_dir.path().join("user");
fs::create_dir_all(&user_config_dir).unwrap();
fs::write(
user_config_dir.join("cgx.toml"),
"resolve_cache_timeout = \"20m\"",
)
.unwrap();
let cwd = temp_dir.path().join("work");
fs::create_dir_all(&cwd).unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.system_config_dir = Some(system_config_dir);
args.user_config_dir = Some(user_config_dir);
let config = Config::load_from_dir(&cwd, &args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(20 * 60));
}
}
mod app_dir_tests {
use super::*;
#[test]
fn test_app_dir_config_location() {
let temp_dir = tempfile::tempdir().unwrap();
let app_dir = temp_dir.path().join("app");
let config_dir = app_dir.join("config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("cgx.toml"), "resolve_cache_timeout = \"7m\"").unwrap();
let cwd = temp_dir.path().join("work");
fs::create_dir_all(&cwd).unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.app_dir = Some(app_dir.clone());
let config = Config::load_from_dir(&cwd, &args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(7 * 60));
assert_eq!(config.config_dir, config_dir);
}
#[test]
fn test_app_dir_cache_location() {
let temp_dir = tempfile::tempdir().unwrap();
let app_dir = temp_dir.path().join("app");
let cwd = temp_dir.path().join("work");
fs::create_dir_all(&cwd).unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.app_dir = Some(app_dir.clone());
let config = Config::load_from_dir(&cwd, &args).unwrap();
assert_eq!(config.cache_dir, app_dir.join("cache"));
}
#[test]
fn test_app_dir_bins_location() {
let temp_dir = tempfile::tempdir().unwrap();
let app_dir = temp_dir.path().join("app");
let cwd = temp_dir.path().join("work");
fs::create_dir_all(&cwd).unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.app_dir = Some(app_dir.clone());
let config = Config::load_from_dir(&cwd, &args).unwrap();
assert_eq!(config.bin_dir, app_dir.join("bins"));
}
#[test]
fn test_app_dir_build_location() {
let temp_dir = tempfile::tempdir().unwrap();
let app_dir = temp_dir.path().join("app");
let cwd = temp_dir.path().join("work");
fs::create_dir_all(&cwd).unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.app_dir = Some(app_dir.clone());
let config = Config::load_from_dir(&cwd, &args).unwrap();
assert_eq!(config.build_dir, app_dir.join("build"));
}
#[test]
fn test_app_dir_complete_isolation() {
let temp_dir = tempfile::tempdir().unwrap();
let app_dir = temp_dir.path().join("app");
let cwd = temp_dir.path().join("work");
fs::create_dir_all(&cwd).unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.app_dir = Some(app_dir.clone());
let config = Config::load_from_dir(&cwd, &args).unwrap();
assert!(config.config_dir.starts_with(&app_dir));
assert!(config.cache_dir.starts_with(&app_dir));
assert!(config.bin_dir.starts_with(&app_dir));
assert!(config.build_dir.starts_with(&app_dir));
}
}
mod user_config_dir_tests {
use super::*;
#[test]
fn test_user_config_dir_cli_arg() {
let temp_dir = tempfile::tempdir().unwrap();
let user_config_dir = temp_dir.path().join("user");
fs::create_dir_all(&user_config_dir).unwrap();
fs::write(user_config_dir.join("cgx.toml"), "resolve_cache_timeout = \"8m\"").unwrap();
let cwd = temp_dir.path().join("work");
fs::create_dir_all(&cwd).unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.user_config_dir = Some(user_config_dir.clone());
let config = Config::load_from_dir(&cwd, &args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(8 * 60));
assert_eq!(config.config_dir, user_config_dir);
}
#[test]
fn test_user_config_dir_overrides_app_dir() {
let temp_dir = tempfile::tempdir().unwrap();
let app_dir = temp_dir.path().join("app");
let app_config_dir = app_dir.join("config");
fs::create_dir_all(&app_config_dir).unwrap();
fs::write(app_config_dir.join("cgx.toml"), "resolve_cache_timeout = \"9m\"").unwrap();
let user_config_dir = temp_dir.path().join("user");
fs::create_dir_all(&user_config_dir).unwrap();
fs::write(
user_config_dir.join("cgx.toml"),
"resolve_cache_timeout = \"11m\"",
)
.unwrap();
let cwd = temp_dir.path().join("work");
fs::create_dir_all(&cwd).unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.app_dir = Some(app_dir.clone());
args.user_config_dir = Some(user_config_dir.clone());
let config = Config::load_from_dir(&cwd, &args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(11 * 60));
assert_eq!(config.config_dir, user_config_dir);
assert_eq!(config.cache_dir, app_dir.join("cache"));
assert_eq!(config.bin_dir, app_dir.join("bins"));
assert_eq!(config.build_dir, app_dir.join("build"));
}
}
mod combined_tests {
use super::*;
#[test]
fn test_all_three_overrides() {
let temp_dir = tempfile::tempdir().unwrap();
let system_config_dir = temp_dir.path().join("system");
fs::create_dir_all(&system_config_dir).unwrap();
fs::write(
system_config_dir.join("cgx.toml"),
"[tools]\nsystem_tool = \"1\"\n[aliases]\ndummytool = \"system\"",
)
.unwrap();
let app_dir = temp_dir.path().join("app");
let app_config_dir = app_dir.join("config");
fs::create_dir_all(&app_config_dir).unwrap();
fs::write(app_config_dir.join("cgx.toml"), "[tools]\napp_tool = \"1\"").unwrap();
let user_config_dir = temp_dir.path().join("user");
fs::create_dir_all(&user_config_dir).unwrap();
fs::write(
user_config_dir.join("cgx.toml"),
"resolve_cache_timeout = \"12m\"\n[tools]\nuser_tool = \"1\"\n[aliases]\ndummytool = \
\"user\"",
)
.unwrap();
let cwd = temp_dir.path().join("work");
fs::create_dir_all(&cwd).unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.system_config_dir = Some(system_config_dir);
args.app_dir = Some(app_dir.clone());
args.user_config_dir = Some(user_config_dir.clone());
let config = Config::load_from_dir(&cwd, &args).unwrap();
assert!(config.tools.contains_key("system_tool"));
assert!(config.tools.contains_key("user_tool"));
assert_eq!(config.tools.len(), 2);
assert_eq!(config.aliases.get("dummytool"), Some(&"user".to_string()));
assert_eq!(config.config_dir, user_config_dir);
assert_eq!(config.cache_dir, app_dir.join("cache"));
assert_eq!(config.bin_dir, app_dir.join("bins"));
assert_eq!(config.build_dir, app_dir.join("build"));
}
#[test]
fn test_hierarchy_still_works_with_overrides() {
let temp_dir = tempfile::tempdir().unwrap();
let app_dir = temp_dir.path().join("app");
let root = temp_dir.path().join("work");
fs::create_dir_all(&root).unwrap();
fs::write(root.join("cgx.toml"), "[tools]\nroot_tool = \"1\"").unwrap();
let sub = root.join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("cgx.toml"), "[tools]\nsub_tool = \"1\"").unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.app_dir = Some(app_dir);
let config = Config::load_from_dir(&sub, &args).unwrap();
assert!(config.tools.contains_key("root_tool"));
assert!(config.tools.contains_key("sub_tool"));
assert_eq!(config.tools.len(), 2);
}
#[test]
fn test_app_dir_takes_precedence_over_config_file() {
let temp_dir = tempfile::tempdir().unwrap();
let app_dir = temp_dir.path().join("app");
let app_config_dir = app_dir.join("config");
fs::create_dir_all(&app_config_dir).unwrap();
let config_file = temp_dir.path().join("explicit.toml");
let test_config = ConfigFile {
cache_dir: Some(temp_dir.path().join("my-cache")),
bin_dir: Some(temp_dir.path().join("my-bins")),
build_dir: Some(temp_dir.path().join("my-build")),
..Default::default()
};
fs::write(&config_file, toml::to_string(&test_config).unwrap()).unwrap();
let cwd = temp_dir.path().join("work");
fs::create_dir_all(&cwd).unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.app_dir = Some(app_dir.clone());
args.config_file = Some(config_file);
let config = Config::load_from_dir(&cwd, &args).unwrap();
assert_eq!(config.cache_dir, app_dir.join("cache"));
assert_eq!(config.bin_dir, app_dir.join("bins"));
assert_eq!(config.build_dir, app_dir.join("build"));
}
#[test]
fn test_config_file_paths_used_when_no_app_dir() {
let temp_dir = tempfile::tempdir().unwrap();
let config_file = temp_dir.path().join("explicit.toml");
let test_config = ConfigFile {
cache_dir: Some(temp_dir.path().join("my-cache")),
bin_dir: Some(temp_dir.path().join("my-bins")),
build_dir: Some(temp_dir.path().join("my-build")),
..Default::default()
};
fs::write(&config_file, toml::to_string(&test_config).unwrap()).unwrap();
let cwd = temp_dir.path().join("work");
fs::create_dir_all(&cwd).unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.config_file = Some(config_file);
let config = Config::load_from_dir(&cwd, &args).unwrap();
assert_eq!(config.cache_dir, temp_dir.path().join("my-cache"));
assert_eq!(config.bin_dir, temp_dir.path().join("my-bins"));
assert_eq!(config.build_dir, temp_dir.path().join("my-build"));
}
}
}
mod http_config_deserialization_tests {
use super::*;
#[test]
fn test_deserialize_http_config_full() {
let toml_content = r#"
[http]
timeout = "2m"
retries = 5
backoff_base = "1s"
backoff_max = "30s"
proxy = "http://proxy.example.com:3128"
"#;
let config: ConfigFile = toml::from_str(toml_content).unwrap();
let http = config.http.unwrap();
assert_eq!(http.timeout, Some(Duration::from_secs(120)));
assert_eq!(http.retries, Some(5));
assert_eq!(http.backoff_base, Some(Duration::from_secs(1)));
assert_eq!(http.backoff_max, Some(Duration::from_secs(30)));
assert_eq!(http.proxy, Some("http://proxy.example.com:3128".to_string()));
}
#[test]
fn test_deserialize_http_config_partial() {
let toml_content = r#"
[http]
timeout = "45s"
retries = 3
"#;
let config: ConfigFile = toml::from_str(toml_content).unwrap();
let http = config.http.unwrap();
assert_eq!(http.timeout, Some(Duration::from_secs(45)));
assert_eq!(http.retries, Some(3));
assert_eq!(http.backoff_base, None);
assert_eq!(http.backoff_max, None);
assert_eq!(http.proxy, None);
}
#[test]
fn test_deserialize_http_config_empty_section() {
let toml_content = r#"
[http]
"#;
let config: ConfigFile = toml::from_str(toml_content).unwrap();
let http = config.http.unwrap();
assert_eq!(http.timeout, None);
assert_eq!(http.retries, None);
assert_eq!(http.backoff_base, None);
assert_eq!(http.backoff_max, None);
assert_eq!(http.proxy, None);
}
#[test]
fn test_deserialize_http_config_unknown_field_rejected() {
let toml_content = r#"
[http]
timeoutt = "30s"
"#;
let result: std::result::Result<ConfigFile, _> = toml::from_str(toml_content);
assert!(result.is_err(), "Expected error for unknown field 'timeoutt'");
}
#[test]
fn test_http_config_default_values() {
let defaults = HttpConfig::default();
assert_eq!(defaults.timeout, DEFAULT_HTTP_TIMEOUT);
assert_eq!(defaults.retries, DEFAULT_HTTP_RETRIES);
assert_eq!(defaults.backoff_base, DEFAULT_HTTP_BACKOFF_BASE);
assert_eq!(defaults.backoff_max, DEFAULT_HTTP_BACKOFF_MAX);
assert_eq!(defaults.proxy, None);
}
}
mod build_http_config_tests {
use super::*;
use assert_matches::assert_matches;
use std::io::Write;
fn create_temp_config(toml_content: &str) -> tempfile::TempDir {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("cgx.toml");
let mut file = std::fs::File::create(&config_path).unwrap();
file.write_all(toml_content.as_bytes()).unwrap();
temp_dir
}
#[test]
fn test_http_config_all_defaults() {
let temp_dir = tempfile::tempdir().unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.system_config_dir = Some(temp_dir.path().join("system"));
args.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.timeout, Duration::from_secs(30));
assert_eq!(config.http.retries, 2);
assert_eq!(config.http.backoff_base, Duration::from_millis(500));
assert_eq!(config.http.backoff_max, Duration::from_secs(5));
assert_eq!(config.http.proxy, None);
}
#[test]
fn test_http_config_from_config_file() {
let toml_content = r#"
[http]
timeout = "2m"
retries = 5
proxy = "http://proxy:3128"
"#;
let temp_dir = create_temp_config(toml_content);
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.system_config_dir = Some(temp_dir.path().join("system"));
args.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.timeout, Duration::from_secs(120));
assert_eq!(config.http.retries, 5);
assert_eq!(config.http.proxy, Some("http://proxy:3128".to_string()));
}
#[test]
fn test_http_config_cli_overrides_config_file() {
let toml_content = r#"
[http]
timeout = "2m"
retries = 5
proxy = "http://proxy:3128"
"#;
let temp_dir = create_temp_config(toml_content);
let mut args = CliArgs::parse_from_test_args([
"--http-timeout",
"10s",
"--http-retries",
"0",
"--http-proxy",
"socks5://other:1080",
"test-crate",
]);
args.system_config_dir = Some(temp_dir.path().join("system"));
args.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.timeout, Duration::from_secs(10));
assert_eq!(config.http.retries, 0);
assert_eq!(config.http.proxy, Some("socks5://other:1080".to_string()));
}
#[test]
fn test_http_config_cli_overrides_partial() {
let toml_content = r#"
[http]
timeout = "2m"
retries = 5
proxy = "http://proxy:3128"
"#;
let temp_dir = create_temp_config(toml_content);
let mut args = CliArgs::parse_from_test_args(["--http-timeout", "10s", "test-crate"]);
args.system_config_dir = Some(temp_dir.path().join("system"));
args.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.timeout, Duration::from_secs(10));
assert_eq!(config.http.retries, 5);
assert_eq!(config.http.proxy, Some("http://proxy:3128".to_string()));
}
#[test]
fn test_http_config_invalid_timeout_duration() {
let temp_dir = tempfile::tempdir().unwrap();
let mut args = CliArgs::parse_from_test_args(["--http-timeout", "not-a-duration", "test-crate"]);
args.system_config_dir = Some(temp_dir.path().join("system"));
args.user_config_dir = Some(temp_dir.path().join("user"));
let result = Config::load_from_dir(temp_dir.path(), &args);
assert_matches!(result, Err(crate::error::Error::InvalidHttpTimeout { .. }));
}
#[test]
fn test_http_config_zero_retries() {
let temp_dir = tempfile::tempdir().unwrap();
let mut args = CliArgs::parse_from_test_args(["--http-retries", "0", "test-crate"]);
args.system_config_dir = Some(temp_dir.path().join("system"));
args.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.retries, 0);
}
#[test]
fn test_http_config_backoff_from_config_file() {
let toml_content = r#"
[http]
backoff_base = "2s"
backoff_max = "60s"
"#;
let temp_dir = create_temp_config(toml_content);
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.system_config_dir = Some(temp_dir.path().join("system"));
args.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.backoff_base, Duration::from_secs(2));
assert_eq!(config.http.backoff_max, Duration::from_secs(60));
}
#[test]
fn test_http_config_backoff_defaults_when_not_in_file() {
let toml_content = r#"
[http]
timeout = "45s"
"#;
let temp_dir = create_temp_config(toml_content);
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.system_config_dir = Some(temp_dir.path().join("system"));
args.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.backoff_base, Duration::from_millis(500));
assert_eq!(config.http.backoff_max, Duration::from_secs(5));
}
#[test]
fn test_http_config_hierarchy_merging_preserves_parent_fields() {
let temp_dir = tempfile::tempdir().unwrap();
let parent = temp_dir.path().join("parent");
std::fs::create_dir_all(&parent).unwrap();
std::fs::write(
parent.join("cgx.toml"),
r#"
[http]
timeout = "1m"
retries = 3
"#,
)
.unwrap();
let child = parent.join("child");
std::fs::create_dir_all(&child).unwrap();
std::fs::write(
child.join("cgx.toml"),
r#"
[http]
timeout = "45s"
"#,
)
.unwrap();
let args =
with_isolated_global_config(CliArgs::parse_from_test_args(["test-crate"]), temp_dir.path());
let config = Config::load_from_dir(&child, &args).unwrap();
assert_eq!(config.http.timeout, Duration::from_secs(45));
assert_eq!(config.http.retries, 3);
}
#[test]
fn test_http_config_hierarchy_merging_child_overrides_parent_fields() {
let temp_dir = tempfile::tempdir().unwrap();
let parent = temp_dir.path().join("parent");
std::fs::create_dir_all(&parent).unwrap();
std::fs::write(
parent.join("cgx.toml"),
r#"
[http]
timeout = "1m"
retries = 3
"#,
)
.unwrap();
let child = parent.join("child");
std::fs::create_dir_all(&child).unwrap();
std::fs::write(
child.join("cgx.toml"),
r#"
[http]
timeout = "45s"
retries = 5
"#,
)
.unwrap();
let args =
with_isolated_global_config(CliArgs::parse_from_test_args(["test-crate"]), temp_dir.path());
let config = Config::load_from_dir(&child, &args).unwrap();
assert_eq!(config.http.timeout, Duration::from_secs(45));
assert_eq!(config.http.retries, 5);
}
}
mod build_http_config_env_tests {
use super::*;
use sealed_test::prelude::*;
use std::io::Write;
fn create_temp_config(toml_content: &str) -> tempfile::TempDir {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("cgx.toml");
let mut file = std::fs::File::create(&config_path).unwrap();
file.write_all(toml_content.as_bytes()).unwrap();
temp_dir
}
#[sealed_test(env = [("CARGO_HTTP_TIMEOUT", "45")])]
fn test_env_timeout_used_when_no_cli_or_config() {
let temp_dir = tempfile::tempdir().unwrap();
let args =
with_isolated_global_config(CliArgs::parse_from_test_args(["test-crate"]), temp_dir.path());
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.timeout, Duration::from_secs(45));
}
#[sealed_test(env = [("CARGO_NET_RETRY", "7")])]
fn test_env_retries_used_when_no_cli_or_config() {
let temp_dir = tempfile::tempdir().unwrap();
let args =
with_isolated_global_config(CliArgs::parse_from_test_args(["test-crate"]), temp_dir.path());
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.retries, 7);
}
#[sealed_test(env = [("CARGO_HTTP_PROXY", "socks5://env-proxy:1080")])]
fn test_env_proxy_used_when_no_cli_or_config() {
let temp_dir = tempfile::tempdir().unwrap();
let args =
with_isolated_global_config(CliArgs::parse_from_test_args(["test-crate"]), temp_dir.path());
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.proxy, Some("socks5://env-proxy:1080".to_string()));
}
#[sealed_test(env = [
("CARGO_HTTP_TIMEOUT", "45"),
("CARGO_NET_RETRY", "7"),
("CARGO_HTTP_PROXY", "http://env-proxy:3128")
])]
fn test_cli_overrides_env() {
let temp_dir = tempfile::tempdir().unwrap();
let args = with_isolated_global_config(
CliArgs::parse_from_test_args([
"--http-timeout",
"10s",
"--http-retries",
"1",
"--http-proxy",
"socks5://cli-proxy:1080",
"test-crate",
]),
temp_dir.path(),
);
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.timeout, Duration::from_secs(10));
assert_eq!(config.http.retries, 1);
assert_eq!(config.http.proxy, Some("socks5://cli-proxy:1080".to_string()));
}
#[sealed_test(env = [
("CARGO_HTTP_TIMEOUT", "45"),
("CARGO_NET_RETRY", "7"),
("CARGO_HTTP_PROXY", "http://env-proxy:3128")
])]
fn test_config_file_overrides_env() {
let toml_content = r#"
[http]
timeout = "2m"
retries = 5
proxy = "http://config-proxy:8080"
"#;
let temp_dir = create_temp_config(toml_content);
let args =
with_isolated_global_config(CliArgs::parse_from_test_args(["test-crate"]), temp_dir.path());
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.timeout, Duration::from_secs(120));
assert_eq!(config.http.retries, 5);
assert_eq!(config.http.proxy, Some("http://config-proxy:8080".to_string()));
}
#[sealed_test(env = [("CARGO_HTTP_TIMEOUT", "not-a-number")])]
fn test_invalid_env_timeout_falls_back_to_default() {
let temp_dir = tempfile::tempdir().unwrap();
let args =
with_isolated_global_config(CliArgs::parse_from_test_args(["test-crate"]), temp_dir.path());
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.timeout, DEFAULT_HTTP_TIMEOUT);
}
#[sealed_test(env = [("CARGO_NET_RETRY", "not-a-number")])]
fn test_invalid_env_retries_falls_back_to_default() {
let temp_dir = tempfile::tempdir().unwrap();
let args =
with_isolated_global_config(CliArgs::parse_from_test_args(["test-crate"]), temp_dir.path());
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.http.retries, DEFAULT_HTTP_RETRIES);
}
}
mod build_http_config_direct_tests {
use super::*;
#[test]
fn test_config_file_timeout_overrides_defaults() {
let config_file = HttpConfigFile {
timeout: Some(Duration::from_secs(120)),
..Default::default()
};
let args = CliArgs::parse_from_test_args(["test-crate"]);
let http = Config::build_http_config(&config_file, &args).unwrap();
assert_eq!(http.timeout, Duration::from_secs(120));
assert_eq!(http.retries, DEFAULT_HTTP_RETRIES);
}
#[test]
fn test_config_file_retries_overrides_defaults() {
let config_file = HttpConfigFile {
retries: Some(10),
..Default::default()
};
let args = CliArgs::parse_from_test_args(["test-crate"]);
let http = Config::build_http_config(&config_file, &args).unwrap();
assert_eq!(http.retries, 10);
}
#[test]
fn test_config_file_proxy_overrides_defaults() {
let config_file = HttpConfigFile {
proxy: Some("http://proxy:3128".to_string()),
..Default::default()
};
let args = CliArgs::parse_from_test_args(["test-crate"]);
let http = Config::build_http_config(&config_file, &args).unwrap();
assert_eq!(http.proxy, Some("http://proxy:3128".to_string()));
}
#[test]
fn test_cli_timeout_overrides_config_file() {
let config_file = HttpConfigFile {
timeout: Some(Duration::from_secs(120)),
..Default::default()
};
let args = CliArgs::parse_from_test_args(["--http-timeout", "10s", "test-crate"]);
let http = Config::build_http_config(&config_file, &args).unwrap();
assert_eq!(http.timeout, Duration::from_secs(10));
}
#[test]
fn test_cli_retries_overrides_config_file() {
let config_file = HttpConfigFile {
retries: Some(10),
..Default::default()
};
let args = CliArgs::parse_from_test_args(["--http-retries", "0", "test-crate"]);
let http = Config::build_http_config(&config_file, &args).unwrap();
assert_eq!(http.retries, 0);
}
#[test]
fn test_cli_proxy_overrides_config_file() {
let config_file = HttpConfigFile {
proxy: Some("http://old:3128".to_string()),
..Default::default()
};
let args = CliArgs::parse_from_test_args(["--http-proxy", "socks5://new:1080", "test-crate"]);
let http = Config::build_http_config(&config_file, &args).unwrap();
assert_eq!(http.proxy, Some("socks5://new:1080".to_string()));
}
#[test]
fn test_empty_config_file_yields_defaults() {
let config_file = HttpConfigFile::default();
let args = CliArgs::parse_from_test_args(["test-crate"]);
let http = Config::build_http_config(&config_file, &args).unwrap();
assert_eq!(http.timeout, DEFAULT_HTTP_TIMEOUT);
assert_eq!(http.retries, DEFAULT_HTTP_RETRIES);
assert_eq!(http.backoff_base, DEFAULT_HTTP_BACKOFF_BASE);
assert_eq!(http.backoff_max, DEFAULT_HTTP_BACKOFF_MAX);
assert_eq!(http.proxy, None);
}
#[test]
fn test_backoff_from_config_file() {
let config_file = HttpConfigFile {
backoff_base: Some(Duration::from_secs(2)),
backoff_max: Some(Duration::from_secs(60)),
..Default::default()
};
let args = CliArgs::parse_from_test_args(["test-crate"]);
let http = Config::build_http_config(&config_file, &args).unwrap();
assert_eq!(http.backoff_base, Duration::from_secs(2));
assert_eq!(http.backoff_max, Duration::from_secs(60));
}
}
mod error_tests {
use super::*;
use assert_matches::assert_matches;
#[test]
fn test_invalid_toml_syntax() {
let test_case = crate::testdata::ConfigTestCase::invalid_toml();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.config_file = Some(test_case.path().to_path_buf());
let result = Config::load(&args);
assert_matches!(result, Err(crate::error::Error::ConfigExtract { .. }));
}
#[test]
fn test_invalid_config_options_raise_error() {
let test_case = crate::testdata::ConfigTestCase::invalid_options();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.config_file = Some(test_case.path().to_path_buf());
let result = Config::load(&args);
assert_matches!(result, Err(crate::error::Error::ConfigExtract { .. }));
}
#[test]
fn test_nonexistent_explicit_config_file() {
let test_case = crate::testdata::ConfigTestCase::nonexistent();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.config_file = Some(test_case.path().to_path_buf());
let config = Config::load(&args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(60 * 60));
}
#[test]
fn test_no_config_files_uses_defaults() {
let temp_dir = tempfile::tempdir().unwrap();
let mut args = CliArgs::parse_from_test_args(["test-crate"]);
args.system_config_dir = Some(temp_dir.path().join("system"));
args.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &args).unwrap();
assert_eq!(config.resolve_cache_timeout, Duration::from_secs(60 * 60));
assert!(!config.offline);
assert!(config.locked); assert_eq!(config.toolchain, None);
assert_eq!(config.tools.len(), 0);
assert_eq!(config.aliases.len(), 0);
}
}
}