Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 86 additions & 25 deletions crates/rattler_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use crate::config::{
build::BuildConfig, concurrency::ConcurrencyConfig, index::IndexConfig, proxy::ProxyConfig,
repodata_config::RepodataConfig, run_post_link_scripts::RunPostLinkScripts,
};
use crate::locations::{ConfigLayer, ConfigLocation};

pub mod build;
pub mod channel_config;
Expand Down Expand Up @@ -493,10 +494,64 @@ where
))
}

/// Parse a *shared* configuration file from a TOML string.
///
/// Shared files (see [`crate::locations::ConfigLayer::Shared`]) may only
/// contain the keys shared by all rattler-based tools: the document is
/// deserialized into [`CommonConfig`] alone and the extension is left at
/// its default. Returns the parsed configuration together with the set
/// of keys [`CommonConfig`] did not recognize, including extension keys
/// the tool itself would understand, so that a shared file means the
/// same thing to every tool reading it.
pub fn from_toml_str_shared(input: &str) -> Result<(Self, BTreeSet<String>), toml::de::Error> {
let mut unknown = BTreeSet::new();
let common: CommonConfig = serde_ignored::deserialize(
toml::de::Deserializer::parse(input)?,
|path: serde_ignored::Path<'_>| {
unknown.insert(path.to_string());
},
)?;

Ok((
Self {
common,
extensions: T::default(),
loaded_from: Vec::new(),
},
unknown,
))
}

/// Parse the file at `path` according to its `layer` and merge it into
/// `self`, warning about ignored keys.
fn merge_from_path(self, path: &Path, layer: ConfigLayer) -> Result<Self, LoadError> {
let content = fs_err::read_to_string(path)?;
let (mut other, unused) = match layer {
ConfigLayer::Shared => Self::from_toml_str_shared(&content)?,
ConfigLayer::Tool => Self::from_toml_str(&content)?,
};
for key in &unused {
match layer {
ConfigLayer::Shared => tracing::warn!(
"Ignoring configuration key `{key}` in {}: not a key shared by all rattler-based tools",
path.display()
),
ConfigLayer::Tool => tracing::warn!(
"Ignoring unknown configuration key `{key}` in {}",
path.display()
),
}
}
other.loaded_from.push(path.to_path_buf());
self.merge_config(&other)
.map_err(|e| LoadError::MergeError(e, path.to_path_buf()))
}

/// Load the configuration by merging all the given files, in order:
/// later files take precedence over earlier ones. Unrecognized keys are
/// reported as `tracing` warnings; the merged configuration is validated
/// before it is returned.
/// later files take precedence over earlier ones. Every file is parsed
/// as a tool file (common keys plus extension keys); unrecognized keys
/// are reported as `tracing` warnings. The merged configuration is
/// validated before it is returned.
///
/// Missing files result in an error; callers that search default
/// locations should filter for existing files first (see
Expand All @@ -505,38 +560,44 @@ where
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
Self::load_from_locations(paths.into_iter().map(|path| ConfigLocation {
path: path.as_ref().to_path_buf(),
layer: ConfigLayer::Tool,
}))
}

/// Load the configuration by merging all the given locations, in order:
/// later locations take precedence over earlier ones. Each file is
/// parsed according to its layer: shared files accept only the common
/// keys (see [`ConfigBase::from_toml_str_shared`]), tool files also
/// accept the extension keys. Unrecognized keys are reported as
/// `tracing` warnings; the merged configuration is validated before it
/// is returned.
pub fn load_from_locations<I>(locations: I) -> Result<Self, LoadError>
where
I: IntoIterator<Item = ConfigLocation>,
{
let mut config = Self::default();

for path in paths {
let path = path.as_ref();
let content = fs_err::read_to_string(path)?;
let (mut other, unused) = Self::from_toml_str(&content)?;
for key in &unused {
tracing::warn!(
"Ignoring unknown configuration key `{key}` in {}",
path.display()
);
}
other.loaded_from.push(path.to_path_buf());
config = config
.merge_config(&other)
.map_err(|e| LoadError::MergeError(e, path.to_path_buf()))?;
for location in locations {
config = config.merge_from_path(&location.path, location.layer)?;
}

config.validate()?;
Ok(config)
}

/// Load the configuration from the default locations of the given tools
/// (e.g. `&["pixi", "rattler-build"]`), skipping files that do not
/// exist. See [`crate::locations::config_search_paths`] for the exact
/// search order.
pub fn load_from_default_locations(tool_dirs: &[&str]) -> Result<Self, LoadError> {
Self::load_from_files(
crate::locations::config_search_paths(tool_dirs)
/// Load the configuration from the default locations of the given tool
/// (e.g. `"rattler-build"`), skipping files that do not exist: the
/// shared `rattler` configuration layered with the tool's own files.
/// See [`crate::locations::config_search_paths`] for the exact search
/// order.
pub fn load_from_default_locations(tool: &str) -> Result<Self, LoadError> {
Self::load_from_locations(
crate::locations::config_search_paths(tool)
.into_iter()
.filter(|path| path.is_file()),
.filter(|location| location.path.is_file()),
)
}
}
84 changes: 80 additions & 4 deletions crates/rattler_config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@
//! [`config::ConfigBase::load_from_files`] merges a list of files in order
//! (later files win) and validates the result.
//! [`config::ConfigBase::load_from_default_locations`] does the same for the
//! conventional locations described in [`locations`], which is how tools
//! share one configuration: e.g. rattler-build can load
//! `&["pixi", "rattler-build"]` to layer its own configuration on top of
//! pixi's.
//! conventional locations described in [`locations`]: the shared `rattler`
//! configuration files, which every rattler-based tool reads and which may
//! only contain the [`config::CommonConfig`] keys, layered with the tool's
//! own files. This is how tools share one configuration without reading
//! each other's files.
//!
//! # Editing
//!
Expand All @@ -76,6 +77,7 @@ pub mod edit;
pub mod locations;

pub use config::{CommonConfig, Config, ConfigBase, LoadError, MergeError, NoExtension};
pub use locations::{ConfigLayer, ConfigLocation};

#[cfg(test)]
mod tests {
Expand Down Expand Up @@ -469,6 +471,80 @@ mod tests {
assert!(unused.contains("custom_field"));
}

#[test]
fn test_from_toml_str_shared_rejects_extension_keys() {
let toml = r#"
default-channels = ["conda-forge"]
tls-no-verify = true
custom_field = "an extension key"
definitely-a-typo = true
"#;

let (config, unused) = TestConfig::from_toml_str_shared(toml).unwrap();

// Common keys are consumed as usual.
assert_eq!(config.default_channels.as_ref().map(Vec::len), Some(1));
assert_eq!(config.tls_no_verify, Some(true));

// A shared file means the same thing to every tool: extension keys
// are reported as unused even though the extension knows them, and
// the extension stays at its default.
assert!(unused.contains("custom_field"));
assert!(unused.contains("definitely-a-typo"));
assert_eq!(config.extensions, TestExtension::default());
}

#[test]
fn test_load_from_locations_layers_shared_and_tool_files() {
use crate::locations::{ConfigLayer, ConfigLocation};

let temp_dir = TempDir::new().unwrap();
let shared_path = temp_dir.path().join("shared.toml");
let tool_path = temp_dir.path().join("tool.toml");
std::fs::write(
&shared_path,
r#"
default-channels = ["conda-forge"]
tls-no-verify = true
"#,
)
.unwrap();
std::fs::write(
&tool_path,
r#"
default-channels = ["bioconda"]
custom_field = "tool files accept extension keys"
"#,
)
.unwrap();

let config = TestConfig::load_from_locations([
ConfigLocation {
path: shared_path.clone(),
layer: ConfigLayer::Shared,
},
ConfigLocation {
path: tool_path.clone(),
layer: ConfigLayer::Tool,
},
])
.unwrap();

// The tool file wins where both set a key…
assert_eq!(
config.default_channels.as_ref().and_then(|c| c.first()),
Some(&"bioconda".parse().unwrap())
);
// …values only in the shared file are kept…
assert_eq!(config.tls_no_verify, Some(true));
// …and extension keys from the tool file are consumed.
assert_eq!(
config.extensions.custom_field.as_deref(),
Some("tool files accept extension keys")
);
assert_eq!(config.loaded_from, vec![shared_path, tool_path]);
}

#[test]
fn test_validation_recurses_into_extension() {
let toml = "numeric_field = 101";
Expand Down
Loading
Loading