use std::{
collections::{BTreeMap, BTreeSet, HashMap},
fmt,
path::{Path, PathBuf},
time::Duration,
};
use etcetera::{AppStrategy, AppStrategyArgs, choose_app_strategy};
use figment::{
Figment,
providers::{Format, Serialized, Toml},
value::magic::RelativePathBuf,
};
use serde::{
Deserialize, Serialize,
de::{self, MapAccess, Visitor, value::MapAccessDeserializer},
};
use snafu::ResultExt;
use strum::{Display, EnumIter, EnumString, IntoStaticStr, VariantNames};
use crate::Result;
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, Serialize)]
#[serde(untagged)]
pub enum ToolConfig {
Version(String),
Detailed(ToolConfigDetailed),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolConfigDetailed {
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub features: Option<Vec<String>>,
#[serde(
rename = "default-features",
default = "default_true",
skip_serializing_if = "is_true"
)]
pub default_features: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub registry: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
}
impl<'de> Deserialize<'de> for ToolConfig {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct ToolConfigVisitor;
impl<'de> Visitor<'de> for ToolConfigVisitor {
type Value = ToolConfig;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a version string or a detailed tool table")
}
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
Ok(ToolConfig::Version(value.to_string()))
}
fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
Ok(ToolConfig::Detailed(ToolConfigDetailed::deserialize(
MapAccessDeserializer::new(map),
)?))
}
}
deserializer.deserialize_any(ToolConfigVisitor)
}
}
impl ToolConfig {
pub fn features(&self) -> Option<&[String]> {
match self {
ToolConfig::Version(_) => None,
ToolConfig::Detailed(ToolConfigDetailed { features, .. }) => features.as_deref(),
}
}
pub fn default_features(&self) -> bool {
match self {
ToolConfig::Version(_) => true,
ToolConfig::Detailed(ToolConfigDetailed { default_features, .. }) => *default_features,
}
}
}
fn is_true(value: &bool) -> bool {
*value
}
fn default_true() -> bool {
true
}
#[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())))
}
}
}
fn tool_path_patch(config_file: &Path) -> Result<Option<ConfigFile>> {
#[derive(Default, Deserialize)]
#[serde(default)]
struct ToolPathPatchFile {
tools: HashMap<String, ToolPathPatchTool>,
}
enum ToolPathPatchTool {
Detailed(ToolPathPatchDetailed),
Version,
}
impl<'de> Deserialize<'de> for ToolPathPatchTool {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct ToolPathPatchToolVisitor;
impl<'de> Visitor<'de> for ToolPathPatchToolVisitor {
type Value = ToolPathPatchTool;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a version string or a detailed tool table")
}
fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
Ok(ToolPathPatchTool::Version)
}
fn visit_string<E>(self, _value: String) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
Ok(ToolPathPatchTool::Version)
}
fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
Ok(ToolPathPatchTool::Detailed(ToolPathPatchDetailed::deserialize(
MapAccessDeserializer::new(map),
)?))
}
}
deserializer.deserialize_any(ToolPathPatchToolVisitor)
}
}
#[derive(Default, Deserialize)]
#[serde(default)]
struct ToolPathPatchDetailed {
path: Option<RelativePathBuf>,
}
let patch_file: ToolPathPatchFile = Figment::from(Toml::file(config_file))
.extract()
.context(crate::error::ConfigExtractSnafu)?;
let tools = patch_file
.tools
.into_iter()
.filter_map(|(name, tool)| match tool {
ToolPathPatchTool::Detailed(ToolPathPatchDetailed { path: Some(path) }) => Some((
name,
ToolConfig::Detailed(ToolConfigDetailed {
version: None,
features: None,
default_features: true,
registry: None,
git: None,
branch: None,
tag: None,
rev: None,
path: Some(path.relative()),
}),
)),
ToolPathPatchTool::Detailed(ToolPathPatchDetailed { path: None })
| ToolPathPatchTool::Version => None,
})
.collect::<HashMap<_, _>>();
if tools.is_empty() {
Ok(None)
} else {
Ok(Some(ConfigFile {
tools: Some(tools),
..ConfigFile::default()
}))
}
}
#[derive(Debug, Clone)]
pub struct Config {
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 verbosity: Verbosity,
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,
verbosity: Verbosity::default(),
default_registry: None,
prebuilt_binaries: PrebuiltBinariesConfig::default(),
http: HttpConfig::default(),
tools: HashMap::default(),
aliases: HashMap::default(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LockMode {
#[default]
Default,
Locked,
Frozen,
Unlocked,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Verbosity {
#[default]
Normal,
Verbose,
VeryVerbose,
ExtremelyVerbose,
}
impl Verbosity {
pub fn from_count(count: u8) -> Self {
match count {
0 => Self::Normal,
1 => Self::Verbose,
2 => Self::VeryVerbose,
_ => Self::ExtremelyVerbose,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct ConfigOverrides {
pub config_file: Option<PathBuf>,
pub system_config_dir: Option<PathBuf>,
pub app_dir: Option<PathBuf>,
pub user_config_dir: Option<PathBuf>,
pub http_timeout: Option<String>,
pub http_retries: Option<usize>,
pub http_proxy: Option<String>,
pub lockfile: LockMode,
pub offline: bool,
pub refresh: bool,
pub prebuilt_binary: Option<UsePrebuiltBinaries>,
pub prebuilt_binary_sources: Option<Vec<BinaryProvider>>,
pub prebuilt_binary_no_verify_checksums: bool,
pub prebuilt_binary_no_verify_signatures: bool,
pub verbosity: Verbosity,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct ConfiguredTool {
pub(crate) name: String,
pub(crate) aliases: Vec<String>,
}
impl Config {
pub fn load(overrides: &ConfigOverrides) -> Result<Self> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Self::load_from_dir(&cwd, overrides)
}
pub fn tools_toml(&self) -> Result<String> {
#[derive(Serialize)]
struct ToolsSection<'a> {
tools: BTreeMap<&'a String, &'a ToolConfig>,
}
#[derive(Serialize)]
struct AliasesSection<'a> {
aliases: BTreeMap<&'a String, &'a String>,
}
fn ensure_section_header(section: &str, body: String) -> String {
let header = format!("[{section}]");
if body.starts_with(&header) {
body
} else {
format!("{header}\n{body}")
}
}
let tools = if self.tools.is_empty() {
"[tools]\n".to_string()
} else {
let body = toml::to_string_pretty(&ToolsSection {
tools: self.sorted_tools(),
})
.context(crate::error::TomlSerializeSnafu)?;
ensure_section_header("tools", body)
};
let aliases = if self.aliases.is_empty() {
"[aliases]\n".to_string()
} else {
let body = toml::to_string_pretty(&AliasesSection {
aliases: self.sorted_aliases(),
})
.context(crate::error::TomlSerializeSnafu)?;
ensure_section_header("aliases", body)
};
Ok(format!("{tools}\n{aliases}"))
}
pub(crate) fn sorted_tools(&self) -> BTreeMap<&String, &ToolConfig> {
self.tools.iter().collect()
}
pub(crate) fn sorted_aliases(&self) -> BTreeMap<&String, &String> {
self.aliases.iter().collect()
}
pub(crate) fn configured_tools(&self) -> Vec<ConfiguredTool> {
let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for name in self.tools.keys().chain(self.aliases.keys()) {
let resolved = self.aliases.get(name).unwrap_or(name);
groups.entry(resolved.clone()).or_default().insert(name.clone());
}
groups
.into_iter()
.map(|(name, members)| {
let aliases = members.into_iter().filter(|member| *member != name).collect();
ConfiguredTool { name, aliases }
})
.collect()
}
pub fn load_from_dir(cwd: &Path, overrides: &ConfigOverrides) -> Result<Self> {
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, overrides)? {
figment = figment.merge(Toml::file(&config_file));
if let Some(path_patch) = tool_path_patch(&config_file)? {
figment = figment.merge(Serialized::defaults(path_patch));
}
}
let config_file: ConfigFile = figment.extract().context(crate::error::ConfigExtractSnafu)?;
let locked = match overrides.lockfile {
LockMode::Unlocked => false,
LockMode::Locked | LockMode::Frozen => true,
LockMode::Default => config_file.locked.unwrap_or(true),
};
let offline = if overrides.offline || overrides.lockfile == LockMode::Frozen {
true
} else {
config_file.offline.unwrap_or(false)
};
let toolchain = config_file.toolchain;
let config_dir = if let Some(user_config_dir) = &overrides.user_config_dir {
user_config_dir.clone()
} else if let Some(app_dir) = &overrides.app_dir {
app_dir.join("config")
} else {
strategy.config_dir()
};
let cache_dir = if let Some(app_dir) = &overrides.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) = &overrides.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) = &overrides.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) = overrides.prebuilt_binary {
prebuilt_binaries.use_prebuilt_binaries = mode;
}
if let Some(ref providers) = overrides.prebuilt_binary_sources {
prebuilt_binaries.binary_providers = providers.clone();
}
if overrides.prebuilt_binary_no_verify_checksums {
prebuilt_binaries.verify_checksums = false;
}
if overrides.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, overrides)?;
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: overrides.refresh,
toolchain,
log_level: config_file.log_level,
verbosity: overrides.verbosity,
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, overrides: &ConfigOverrides) -> Result<Vec<PathBuf>> {
let mut config_files = Vec::new();
if let Some(config_path) = &overrides.config_file {
return Ok(vec![config_path.clone()]);
}
if let Some(system_config_dir) = &overrides.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) = &overrides.user_config_dir {
user_config_dir.join("cgx.toml")
} else if let Some(app_dir) = &overrides.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, overrides: &ConfigOverrides) -> Result<HttpConfig> {
let cli_timeout = overrides.http_timeout.as_ref();
let cli_retries = overrides.http_retries;
let cli_proxy = overrides.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 std::path::Path;
use assert_matches::assert_matches;
use super::*;
use crate::cli::Cli;
fn with_isolated_global_config(mut overrides: ConfigOverrides, root: &Path) -> ConfigOverrides {
overrides.system_config_dir = Some(root.join("system"));
overrides.user_config_dir = Some(root.join("user"));
overrides
}
#[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(ToolConfigDetailed {
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_tools_default_features() {
let toml_content = r#"
[tools]
no-defaults = { version = "1.0", default-features = false }
with-defaults = { version = "1.0" }
"#;
let config: ConfigFile = toml::from_str(toml_content).unwrap();
let tools = config.tools.unwrap();
assert!(!tools.get("no-defaults").unwrap().default_features());
assert!(tools.get("with-defaults").unwrap().default_features());
let underscore = r#"
[tools]
nope = { version = "1.0", default_features = false }
"#;
assert_matches!(toml::from_str::<ConfigFile>(underscore), Err(_));
}
#[test]
fn test_default_features_round_trips_via_tools_toml() {
let mut config = Config::default();
config.tools.insert(
"no-defaults".to_string(),
ToolConfig::Detailed(ToolConfigDetailed {
default_features: false,
version: Some("1.0".to_string()),
features: None,
registry: None,
git: None,
branch: None,
tag: None,
rev: None,
path: None,
}),
);
config.tools.insert(
"with-defaults".to_string(),
ToolConfig::Detailed(ToolConfigDetailed {
default_features: true,
version: Some("1.0".to_string()),
features: None,
registry: None,
git: None,
branch: None,
tag: None,
rev: None,
path: None,
}),
);
let rendered = config.tools_toml().unwrap();
assert!(rendered.contains("default-features = false"));
assert!(!rendered.contains("default-features = true"));
let parsed: ConfigFile = toml::from_str(&rendered).unwrap();
let tools = parsed.tools.unwrap();
assert!(!tools.get("no-defaults").unwrap().default_features());
assert!(tools.get("with-defaults").unwrap().default_features());
}
#[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_tools_toml_is_sorted_and_valid() {
let mut config = Config::default();
config
.tools
.insert("zeta".to_string(), ToolConfig::Version("2".to_string()));
config
.tools
.insert("alpha".to_string(), ToolConfig::Version("1".to_string()));
config.tools.insert(
"beta".to_string(),
ToolConfig::Detailed(ToolConfigDetailed {
default_features: true,
version: Some("1.5".to_string()),
features: Some(vec!["frobnulator".to_string()]),
registry: None,
git: None,
branch: None,
tag: None,
rev: None,
path: None,
}),
);
config.aliases.insert("zz".to_string(), "zeta".to_string());
config.aliases.insert("aa".to_string(), "alpha".to_string());
let rendered = config.tools_toml().unwrap();
let parsed: ConfigFile = toml::from_str(&rendered).unwrap();
let tools = parsed.tools.unwrap();
assert_eq!(tools.get("alpha"), Some(&ToolConfig::Version("1".to_string())));
assert_eq!(tools.get("zeta"), Some(&ToolConfig::Version("2".to_string())));
assert_matches!(
tools.get("beta"),
Some(ToolConfig::Detailed(ToolConfigDetailed {
version: Some(version),
features: Some(features),
..
})) if version == "1.5" && features == &vec!["frobnulator".to_string()]
);
let aliases = parsed.aliases.unwrap();
assert_eq!(aliases.get("aa"), Some(&"alpha".to_string()));
assert_eq!(aliases.get("zz"), Some(&"zeta".to_string()));
assert!(rendered.find("alpha").unwrap() < rendered.find("zeta").unwrap());
assert!(rendered.find("aa").unwrap() < rendered.find("zz").unwrap());
}
fn config_with(tools: &[&str], aliases: &[(&str, &str)]) -> Config {
let mut config = Config::default();
for &tool in tools {
config
.tools
.insert(tool.to_string(), ToolConfig::Version("*".to_string()));
}
for &(name, target) in aliases {
config.aliases.insert(name.to_string(), target.to_string());
}
config
}
#[test]
fn configured_tools_groups_alias_with_its_tool() {
let config = config_with(&["eza"], &[("e", "eza")]);
assert_eq!(
config.configured_tools(),
[ConfiguredTool {
name: "eza".to_string(),
aliases: vec!["e".to_string()],
}]
);
}
#[test]
fn configured_tools_alias_to_unconfigured_crate_yields_its_target() {
let config = config_with(&[], &[("x", "ripgrep")]);
assert_eq!(
config.configured_tools(),
[ConfiguredTool {
name: "ripgrep".to_string(),
aliases: vec!["x".to_string()],
}]
);
}
#[test]
fn configured_tools_groups_multiple_aliases_under_one_tool() {
let config = config_with(&["eza"], &[("e", "eza"), ("ez", "eza")]);
assert_eq!(
config.configured_tools(),
[ConfiguredTool {
name: "eza".to_string(),
aliases: vec!["e".to_string(), "ez".to_string()],
}]
);
}
#[test]
fn configured_tools_are_deterministically_ordered() {
let config = config_with(&["zoxide", "eza"], &[("a", "zoxide")]);
let names: Vec<_> = config
.configured_tools()
.into_iter()
.map(|tool| tool.name)
.collect();
assert_eq!(names, ["eza", "zoxide"]);
}
#[test]
fn configured_tools_keep_independent_tools_separate() {
let config = config_with(&["eza", "ripgrep"], &[]);
assert_eq!(
config.configured_tools(),
[
ConfiguredTool {
name: "eza".to_string(),
aliases: Vec::new(),
},
ConfiguredTool {
name: "ripgrep".to_string(),
aliases: Vec::new(),
},
]
);
}
#[test]
fn tools_toml_empty_config_still_renders_both_headers() {
let rendered = Config::default().tools_toml().unwrap();
assert!(rendered.contains("[tools]"), "missing [tools] in:\n{rendered}");
assert!(
rendered.contains("[aliases]"),
"missing [aliases] in:\n{rendered}"
);
let parsed: ConfigFile = toml::from_str(&rendered).unwrap();
assert!(parsed.tools.unwrap_or_default().is_empty());
assert!(parsed.aliases.unwrap_or_default().is_empty());
}
#[test]
fn tools_toml_tools_only_still_renders_aliases_header() {
let config = config_with(&["ripgrep"], &[]);
let rendered = config.tools_toml().unwrap();
assert!(rendered.contains("[tools]"));
assert!(rendered.contains("[aliases]"));
let parsed: ConfigFile = toml::from_str(&rendered).unwrap();
assert!(parsed.tools.unwrap().contains_key("ripgrep"));
}
#[test]
fn tools_toml_aliases_only_still_renders_tools_header() {
let config = config_with(&[], &[("rg", "ripgrep")]);
let rendered = config.tools_toml().unwrap();
assert!(rendered.contains("[tools]"));
assert!(rendered.contains("[aliases]"));
let parsed: ConfigFile = toml::from_str(&rendered).unwrap();
assert_eq!(parsed.aliases.unwrap().get("rg"), Some(&"ripgrep".to_string()));
}
#[test]
fn tools_toml_all_detailed_entries_still_render_bare_tools_header() {
let mut config = Config::default();
config.tools.insert(
"only-detailed".to_string(),
ToolConfig::Detailed(ToolConfigDetailed {
version: Some("1.0".to_string()),
features: Some(vec!["x".to_string()]),
default_features: true,
registry: None,
git: None,
branch: None,
tag: None,
rev: None,
path: None,
}),
);
let rendered = config.tools_toml().unwrap();
assert!(
rendered.lines().any(|line| line.trim() == "[tools]"),
"missing bare [tools] header in:\n{rendered}"
);
let parsed: ConfigFile = toml::from_str(&rendered).unwrap();
assert!(parsed.tools.unwrap().contains_key("only-detailed"));
}
#[test]
fn tool_config_unknown_key_error_names_the_field() {
let toml_content = r#"
[tools]
ripgrep = { versio = "14" } # spellchecker:disable-line
"#;
let error = toml::from_str::<ConfigFile>(toml_content)
.unwrap_err()
.to_string();
assert!(
error.contains("versio"), "error did not name the bad key:\n{error}"
);
assert!(
!error.contains("did not match any variant"),
"error was the opaque untagged message:\n{error}"
);
}
#[test]
fn tool_config_type_mismatch_error_is_precise() {
let toml_content = r#"
[tools]
ripgrep = { default-features = "false" }
"#;
let error = toml::from_str::<ConfigFile>(toml_content)
.unwrap_err()
.to_string();
assert!(
error.contains("boolean"),
"error did not describe the expected type:\n{error}"
);
assert!(
!error.contains("did not match any variant"),
"error was the opaque untagged message:\n{error}"
);
}
fn toml_path(path: &Path) -> String {
path.display().to_string().replace('\\', "\\\\")
}
fn patched_tool_path(patch: &ConfigFile, tool_name: &str) -> PathBuf {
let tools = patch.tools.as_ref().unwrap();
match tools.get(tool_name) {
Some(ToolConfig::Detailed(ToolConfigDetailed { path: Some(path), .. })) => path.clone(),
other => panic!("expected detailed tool path for {tool_name}, got {other:?}"),
}
}
#[test]
fn tool_path_patch_resolves_relative_path_from_config_file() {
let temp_dir = tempfile::tempdir().unwrap();
let project_dir = temp_dir.path().join("project");
std::fs::create_dir_all(&project_dir).unwrap();
let config_path = project_dir.join("cgx.toml");
std::fs::write(
&config_path,
r#"
[tools]
local-tool = { path = "tools/local-tool" }
"#,
)
.unwrap();
let patch = tool_path_patch(&config_path).unwrap().unwrap();
assert_eq!(
patched_tool_path(&patch, "local-tool"),
project_dir.join("tools/local-tool")
);
}
#[test]
fn tool_path_patch_leaves_absolute_path_unchanged() {
let temp_dir = tempfile::tempdir().unwrap();
let absolute_path = temp_dir.path().join("tools").join("local-tool");
let config_path = temp_dir.path().join("cgx.toml");
std::fs::write(
&config_path,
format!(
r#"
[tools]
local-tool = {{ path = "{}" }}
"#,
toml_path(&absolute_path)
),
)
.unwrap();
let patch = tool_path_patch(&config_path).unwrap().unwrap();
assert_eq!(patched_tool_path(&patch, "local-tool"), absolute_path);
}
#[test]
fn tool_path_patch_ignores_tools_without_paths() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("cgx.toml");
std::fs::write(
&config_path,
r#"
[tools]
string-tool = "1"
detailed-tool = { version = "1" }
"#,
)
.unwrap();
let patch = tool_path_patch(&config_path).unwrap();
assert!(patch.is_none());
}
#[test]
fn tool_path_patch_does_not_hide_unknown_tool_fields() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("cgx.toml");
std::fs::write(
&config_path,
r#"
[tools]
local-tool = { path = "tools/local-tool", versio = "1" } # spellchecker:disable-line
"#,
)
.unwrap();
let patch = tool_path_patch(&config_path).unwrap().unwrap();
let figment = Figment::new()
.merge(Serialized::defaults(ConfigFile::base_config()))
.merge(Toml::file(&config_path))
.merge(Serialized::defaults(patch));
assert_matches!(figment.extract::<ConfigFile>(), Err(_));
}
#[test]
fn config_hierarchy_merges_parent_path_with_child_version() {
let temp_dir = tempfile::tempdir().unwrap();
let parent = temp_dir.path().join("parent");
let child = parent.join("child");
std::fs::create_dir_all(&child).unwrap();
std::fs::write(
parent.join("cgx.toml"),
r#"
[tools]
local-tool = { path = "tools/local-tool" }
"#,
)
.unwrap();
std::fs::write(
child.join("cgx.toml"),
r#"
[tools]
local-tool = { version = "1" }
"#,
)
.unwrap();
let config_overrides = with_isolated_global_config(
Cli::parse_from_test_args(["local-tool"]).to_config_overrides(),
temp_dir.path(),
);
let config = Config::load_from_dir(&child, &config_overrides).unwrap();
assert_matches!(
config.tools.get("local-tool"),
Some(ToolConfig::Detailed(ToolConfigDetailed {
version: Some(version),
path: Some(path),
..
})) if version == "1" && path == &parent.join("tools/local-tool")
);
}
#[test]
fn test_config_defaults() {
let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let config = Config::load(&config_overrides).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 cli = Cli::parse_from_test_args(["+nightly", "--offline", "--locked", "test-crate"]);
let config_overrides = cli.to_config_overrides();
let config = Config::load(&config_overrides).unwrap();
let build_overrides = if let Cli::Run { args, .. } = cli {
args.to_build_overrides()
} else {
panic!("Expected Run command")
};
assert!(config.offline);
assert!(config.locked);
assert_eq!(config.toolchain, None);
assert_eq!(build_overrides.toolchain, Some("nightly".to_string()));
}
#[test]
fn test_frozen_implies_locked_and_offline() {
let config_overrides = Cli::parse_from_test_args(["--frozen", "test-crate"]).to_config_overrides();
let config = Config::load(&config_overrides).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 std::io::Write;
use assert_matches::assert_matches;
use super::*;
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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let result = Config::load_from_dir(temp_dir.path(), &config_overrides);
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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let result = Config::load_from_dir(temp_dir.path(), &config_overrides);
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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let result = Config::load_from_dir(temp_dir.path(), &config_overrides);
assert!(result.is_ok(), "Empty providers with 'never' mode should succeed");
}
}
mod hierarchy_tests {
use assert_matches::assert_matches;
use super::*;
use crate::builder::BuildOptions;
#[test]
fn test_config_hierarchy_project1() {
let test_case = crate::testdata::ConfigTestCase::hierarchy_project1();
let config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let config = Config::load_from_dir(test_case.path(), &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let config = Config::load_from_dir(test_case.path(), &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let config = Config::load_from_dir(test_case.path(), &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let config = Config::load_from_dir(test_case.path(), &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.config_file = Some(test_case.path().to_path_buf());
let config = Config::load(&config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let config = Config::load_from_dir(test_case.path(), &config_overrides).unwrap();
let taplo_tool = config.tools.get("taplo-cli").unwrap();
assert_matches!(
taplo_tool,
ToolConfig::Detailed(ToolConfigDetailed {
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 cli = Cli::parse_from_test_args(["+stable", "--offline", "--locked", "test-crate"]);
let config_overrides = cli.to_config_overrides();
let build_overrides = if let Cli::Run { args, .. } = cli {
args.to_build_overrides()
} else {
panic!("Expected Run command")
};
let config = Config::load_from_dir(test_case.path(), &config_overrides).unwrap();
let build_options = BuildOptions::load(&config, &build_overrides).unwrap();
assert!(config.offline);
assert!(config.locked);
assert_eq!(config.toolchain, None);
assert_eq!(build_options.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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.config_file = Some(explicit_config.path().to_path_buf());
let config = Config::load_from_dir(hierarchy_dir.path(), &config_overrides).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 std::fs;
use super::*;
#[test]
fn test_discover_only_explicit_file() {
struct UserConfigGuard {
path: PathBuf,
should_delete: bool,
}
impl Drop for UserConfigGuard {
fn drop(&mut self) {
if self.should_delete {
let _ = fs::remove_file(&self.path);
}
}
}
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();
let _ = fs::create_dir_all(&user_config_dir);
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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.config_file = Some(explicit_config.clone());
let discovered = Config::discover_config_files(&sub_dir, &config_overrides).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() {
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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let discovered = Config::discover_config_files(&sub_dir, &config_overrides).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 std::fs;
use super::*;
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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.system_config_dir = Some(system_config_dir);
config_overrides.user_config_dir = Some(user_config_dir);
let config = Config::load_from_dir(&cwd, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.system_config_dir = Some(system_config_dir);
config_overrides.user_config_dir = Some(user_config_dir);
let config = Config::load_from_dir(&cwd, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.app_dir = Some(app_dir.clone());
let config = Config::load_from_dir(&cwd, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.app_dir = Some(app_dir.clone());
let config = Config::load_from_dir(&cwd, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.app_dir = Some(app_dir.clone());
let config = Config::load_from_dir(&cwd, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.app_dir = Some(app_dir.clone());
let config = Config::load_from_dir(&cwd, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.app_dir = Some(app_dir.clone());
let config = Config::load_from_dir(&cwd, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.user_config_dir = Some(user_config_dir.clone());
let config = Config::load_from_dir(&cwd, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.app_dir = Some(app_dir.clone());
config_overrides.user_config_dir = Some(user_config_dir.clone());
let config = Config::load_from_dir(&cwd, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.system_config_dir = Some(system_config_dir);
config_overrides.app_dir = Some(app_dir.clone());
config_overrides.user_config_dir = Some(user_config_dir.clone());
let config = Config::load_from_dir(&cwd, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.app_dir = Some(app_dir);
let config = Config::load_from_dir(&sub, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.app_dir = Some(app_dir.clone());
config_overrides.config_file = Some(config_file);
let config = Config::load_from_dir(&cwd, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.config_file = Some(config_file);
let config = Config::load_from_dir(&cwd, &config_overrides).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 std::io::Write;
use assert_matches::assert_matches;
use super::*;
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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides = Cli::parse_from_test_args([
"--http-timeout",
"10s",
"--http-retries",
"0",
"--http-proxy",
"socks5://other:1080",
"test-crate",
])
.to_config_overrides();
config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides =
Cli::parse_from_test_args(["--http-timeout", "10s", "test-crate"]).to_config_overrides();
config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides =
Cli::parse_from_test_args(["--http-timeout", "not-a-duration", "test-crate"])
.to_config_overrides();
config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
let result = Config::load_from_dir(temp_dir.path(), &config_overrides);
assert_matches!(result, Err(crate::error::Error::InvalidHttpTimeout { .. }));
}
#[test]
fn test_http_config_zero_retries() {
let temp_dir = tempfile::tempdir().unwrap();
let mut config_overrides =
Cli::parse_from_test_args(["--http-retries", "0", "test-crate"]).to_config_overrides();
config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides = with_isolated_global_config(
Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
temp_dir.path(),
);
let config = Config::load_from_dir(&child, &config_overrides).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 config_overrides = with_isolated_global_config(
Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
temp_dir.path(),
);
let config = Config::load_from_dir(&child, &config_overrides).unwrap();
assert_eq!(config.http.timeout, Duration::from_secs(45));
assert_eq!(config.http.retries, 5);
}
}
mod build_http_config_env_tests {
use std::io::Write;
use sealed_test::prelude::*;
use super::*;
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 config_overrides = with_isolated_global_config(
Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
temp_dir.path(),
);
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides = with_isolated_global_config(
Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
temp_dir.path(),
);
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides = with_isolated_global_config(
Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
temp_dir.path(),
);
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides = with_isolated_global_config(
Cli::parse_from_test_args([
"--http-timeout",
"10s",
"--http-retries",
"1",
"--http-proxy",
"socks5://cli-proxy:1080",
"test-crate",
])
.to_config_overrides(),
temp_dir.path(),
);
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides = with_isolated_global_config(
Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
temp_dir.path(),
);
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides = with_isolated_global_config(
Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
temp_dir.path(),
);
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides = with_isolated_global_config(
Cli::parse_from_test_args(["test-crate"]).to_config_overrides(),
temp_dir.path(),
);
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let http = Config::build_http_config(&config_file, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let http = Config::build_http_config(&config_file, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let http = Config::build_http_config(&config_file, &config_overrides).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 config_overrides =
Cli::parse_from_test_args(["--http-timeout", "10s", "test-crate"]).to_config_overrides();
let http = Config::build_http_config(&config_file, &config_overrides).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 config_overrides =
Cli::parse_from_test_args(["--http-retries", "0", "test-crate"]).to_config_overrides();
let http = Config::build_http_config(&config_file, &config_overrides).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 config_overrides =
Cli::parse_from_test_args(["--http-proxy", "socks5://new:1080", "test-crate"])
.to_config_overrides();
let http = Config::build_http_config(&config_file, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let http = Config::build_http_config(&config_file, &config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
let http = Config::build_http_config(&config_file, &config_overrides).unwrap();
assert_eq!(http.backoff_base, Duration::from_secs(2));
assert_eq!(http.backoff_max, Duration::from_secs(60));
}
}
mod error_tests {
use assert_matches::assert_matches;
use super::*;
#[test]
fn test_invalid_toml_syntax() {
let test_case = crate::testdata::ConfigTestCase::invalid_toml();
let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.config_file = Some(test_case.path().to_path_buf());
let result = Config::load(&config_overrides);
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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.config_file = Some(test_case.path().to_path_buf());
let result = Config::load(&config_overrides);
assert_matches!(result, Err(crate::error::Error::ConfigExtract { .. }));
}
#[test]
fn test_nonexistent_explicit_config_file() {
let test_case = crate::testdata::ConfigTestCase::nonexistent();
let mut config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.config_file = Some(test_case.path().to_path_buf());
let config = Config::load(&config_overrides).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 config_overrides = Cli::parse_from_test_args(["test-crate"]).to_config_overrides();
config_overrides.system_config_dir = Some(temp_dir.path().join("system"));
config_overrides.user_config_dir = Some(temp_dir.path().join("user"));
let config = Config::load_from_dir(temp_dir.path(), &config_overrides).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);
}
}
}