From a5309e675a5ea2a318be13570cee87b64836636c Mon Sep 17 00:00:00 2001 From: aoright <102943475+aoright@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:19:39 +0800 Subject: [PATCH] fix(config): support all configuration keys and subkeys in pixi config list Support all configuration keys and subkeys in `pixi config list [KEY]`. Previously, `partial_config()` in `crates/pixi_cli/src/config.rs` hardcoded a subset of 12 keys and returned an error for valid configuration keys like `tls-root-certs`, `concurrency`, `detached-environments`, `pinning-strategy`, `run-post-link-scripts`, `s3-options`, `tool-platform`, `cache`, `experimental`, and `build`. Furthermore, dotted subkeys were unsupported. This change: - Supports all top-level keys and dotted subkeys in `partial_config`. - Trims/prunes subkey outputs for `concurrency.*` and `s3-options.*` in TOML/JSON. - Uses `config.get_keys()` dynamically for error diagnostics when an invalid key is provided. - Adds `build` to `Config::get_keys()` and deserialization in `Config::set()`. - Adds comprehensive unit tests covering all configuration keys and subkey pruning. Relates to #6825 Signed-off-by: aoright <102943475+aoright@users.noreply.github.com> --- crates/pixi_cli/src/config.rs | 474 ++++++++++++++++++++++++++++++++-- crates/pixi_config/src/lib.rs | 16 +- 2 files changed, 469 insertions(+), 21 deletions(-) diff --git a/crates/pixi_cli/src/config.rs b/crates/pixi_cli/src/config.rs index dcc9ae81ef..a5b65948e4 100644 --- a/crates/pixi_cli/src/config.rs +++ b/crates/pixi_cli/src/config.rs @@ -165,21 +165,26 @@ pub async fn execute(args: Args) -> miette::Result<()> { Subcommand::List(args) => { let mut config = load_config(&args.common, &args.config_source.source())?; - if let Some(key) = args.key { - partial_config(&mut config, &key)?; + if let Some(key) = &args.key { + partial_config(&mut config, key)?; } - let out = if args.json { + let mut out = if args.json { serde_json::to_string_pretty(&config).into_diagnostic()? } else { toml_edit::ser::to_string_pretty(&config).into_diagnostic()? }; - if out.is_empty() { + if let Some(key) = &args.key { + prune_subkeys(&mut out, key, args.json)?; + } + + if out.trim().is_empty() { eprintln!("Configuration not set"); + } else { + pixi_utils::io::ignore_broken_pipe(writeln!(std::io::stdout(), "{out}")) + .into_diagnostic()?; } - pixi_utils::io::ignore_broken_pipe(writeln!(std::io::stdout(), "{out}")) - .into_diagnostic()?; } Subcommand::Prepend(args) => alter_config( &args.common, @@ -345,9 +350,11 @@ fn partial_config(config: &mut Config, key: &str) -> miette::Result<()> { let mut new = Config::default(); match key { + // Top-level configuration fields "default-channels" => new.default_channels = config.default_channels.clone(), "shell" => new.shell = config.shell.clone(), "tls-no-verify" => new.tls_no_verify = config.tls_no_verify, + "tls-root-certs" => new.tls_root_certs = config.tls_root_certs, "offline" => new.offline = config.offline, "authentication-override-file" => { new.authentication_override_file = config.authentication_override_file.clone() @@ -360,21 +367,118 @@ fn partial_config(config: &mut Config, key: &str) -> miette::Result<()> { "allow-symbolic-links" => new.allow_symbolic_links = config.allow_symbolic_links, "allow-hard-links" => new.allow_hard_links = config.allow_hard_links, "allow-ref-links" => new.allow_ref_links = config.allow_ref_links, + "detached-environments" => new.detached_environments = config.detached_environments.clone(), + "pinning-strategy" => new.pinning_strategy = config.pinning_strategy, + "concurrency" => new.concurrency = config.concurrency.clone(), + "run-post-link-scripts" => new.run_post_link_scripts = config.run_post_link_scripts.clone(), + "s3-options" => new.s3_options = config.s3_options.clone(), + "tool-platform" => new.tool_platform = config.tool_platform, + "cache" => new.cache = config.cache.clone(), + "experimental" => new.experimental = config.experimental.clone(), + "build" => new.build = config.build.clone(), + + // Subkeys: shell + "shell.change-ps1" => new.shell.change_ps1 = config.shell.change_ps1, + "shell.force-activate" => new.shell.force_activate = config.shell.force_activate, + "shell.source-completion-scripts" => { + new.shell.source_completion_scripts = config.shell.source_completion_scripts; + } + + // Subkeys: cache + "cache.root" => new.cache.root = config.cache.root.clone(), + "cache.conda-packages" => new.cache.conda_packages = config.cache.conda_packages.clone(), + "cache.repodata" => new.cache.repodata = config.cache.repodata.clone(), + "cache.pypi-wheels" => new.cache.pypi_wheels = config.cache.pypi_wheels.clone(), + "cache.pypi-mapping" => new.cache.pypi_mapping = config.cache.pypi_mapping.clone(), + "cache.exec-environments" => { + new.cache.exec_environments = config.cache.exec_environments.clone() + } + "cache.build-tool-environments" => { + new.cache.build_tool_environments = config.cache.build_tool_environments.clone() + } + "cache.detached-environments" => { + new.cache.detached_environments = config.cache.detached_environments.clone() + } + "cache.netfs-redirect" => new.cache.netfs_redirect = config.cache.netfs_redirect, + + // Subkeys: proxy-config + "proxy-config.http" => new.proxy_config.http = config.proxy_config.http.clone(), + "proxy-config.https" => new.proxy_config.https = config.proxy_config.https.clone(), + "proxy-config.non-proxy-hosts" => { + new.proxy_config.non_proxy_hosts = config.proxy_config.non_proxy_hosts.clone() + } + + // Subkeys: pypi-config + "pypi-config.index-url" => new.pypi_config.index_url = config.pypi_config.index_url.clone(), + "pypi-config.extra-index-urls" => { + new.pypi_config.extra_index_urls = config.pypi_config.extra_index_urls.clone() + } + "pypi-config.keyring-provider" => { + new.pypi_config.keyring_provider = config.pypi_config.keyring_provider.clone() + } + "pypi-config.allow-insecure-host" => { + new.pypi_config.allow_insecure_host = config.pypi_config.allow_insecure_host.clone() + } + + // Subkeys: repodata-config + "repodata-config.disable-bzip2" => { + new.repodata_config.default.disable_bzip2 = config.repodata_config.default.disable_bzip2 + } + "repodata-config.disable-sharded" => { + new.repodata_config.default.disable_sharded = + config.repodata_config.default.disable_sharded + } + "repodata-config.disable-zstd" => { + new.repodata_config.default.disable_zstd = config.repodata_config.default.disable_zstd + } + + // Subkeys: index-config + "index-config.base-url" => { + new.index_config.default.base_url = config.index_config.default.base_url.clone() + } + "index-config.write-shards" => { + new.index_config.default.write_shards = config.index_config.default.write_shards + } + "index-config.write-zst" => { + new.index_config.default.write_zst = config.index_config.default.write_zst + } + + // Subkeys: experimental + "experimental.conda-script" => { + new.experimental.conda_script = config.experimental.conda_script + } + "experimental.use-environment-activation-cache" => { + new.experimental.use_environment_activation_cache = + config.experimental.use_environment_activation_cache + } + + // Subkeys: concurrency + "concurrency.downloads" => { + new.concurrency = config.concurrency.clone(); + } + "concurrency.solves" => { + new.concurrency = config.concurrency.clone(); + } + + // Subkeys: s3-options + key if key.starts_with("s3-options.") => { + if let Some(subkey) = key.strip_prefix("s3-options.") { + if let Some((bucket, rest)) = subkey.split_once('.') { + if !["endpoint-url", "region", "force-path-style"].contains(&rest) { + let keys = config.get_keys(); + return Err(miette::miette!("key must be one of: {}", keys.join(", "))); + } + if let Some(opts) = config.s3_options.0.get(bucket) { + new.s3_options.0.insert(bucket.to_string(), opts.clone()); + } + } else if let Some(opts) = config.s3_options.0.get(subkey) { + new.s3_options.0.insert(subkey.to_string(), opts.clone()); + } + } + } + _ => { - let keys = [ - "default-channels", - "tls-no-verify", - "offline", - "authentication-override-file", - "mirrors", - "repodata-config", - "index-config", - "pypi-config", - "proxy-config", - "allow-symbolic-links", - "allow-hard-links", - "allow-ref-links", - ]; + let keys = config.get_keys(); return Err(miette::miette!("key must be one of: {}", keys.join(", "))); } } @@ -383,3 +487,333 @@ fn partial_config(config: &mut Config, key: &str) -> miette::Result<()> { Ok(()) } + +fn prune_subkeys(out: &mut String, key: &str, json: bool) -> miette::Result<()> { + if let Some(field) = key.strip_prefix("concurrency.") { + if json { + if let Ok(mut json_val) = serde_json::from_str::(out) + && let Some(concurrency) = json_val + .get_mut("concurrency") + .and_then(|v| v.as_object_mut()) + { + concurrency.retain(|k, _| k == field); + *out = serde_json::to_string_pretty(&json_val).into_diagnostic()?; + } + } else if let Ok(mut doc) = out.parse::() + && let Some(table) = doc + .get_mut("concurrency") + .and_then(|i| i.as_table_like_mut()) + { + let to_remove: Vec = table + .iter() + .map(|(k, _)| k.to_string()) + .filter(|k| k != field) + .collect(); + for k in to_remove { + table.remove(&k); + } + *out = doc.to_string(); + } + } else if let Some(rest) = key.strip_prefix("s3-options.") + && let Some((bucket, field)) = rest.split_once('.') + { + if json { + if let Ok(mut json_val) = serde_json::from_str::(out) + && let Some(bucket_obj) = json_val + .get_mut("s3-options") + .and_then(|v| v.get_mut(bucket)) + .and_then(|v| v.as_object_mut()) + { + bucket_obj.retain(|k, _| k == field); + *out = serde_json::to_string_pretty(&json_val).into_diagnostic()?; + } + } else if let Ok(mut doc) = out.parse::() + && let Some(bucket_table) = doc + .get_mut("s3-options") + .and_then(|i| i.as_table_like_mut()) + .and_then(|t| t.get_mut(bucket)) + .and_then(|i| i.as_table_like_mut()) + { + let to_remove: Vec = bucket_table + .iter() + .map(|(k, _)| k.to_string()) + .filter(|k| k != field) + .collect(); + for k in to_remove { + bucket_table.remove(&k); + } + *out = doc.to_string(); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pixi_config::{ + CacheConfig, ConcurrencyConfig, Config, ExperimentalConfig, IndexChannelConfig, + IndexConfig, ProxyConfig, PyPIConfig, RepodataChannelConfig, RepodataConfig, ShellConfig, + TlsRootCerts, + }; + use std::path::PathBuf; + + #[test] + fn test_partial_config_tls_root_certs() { + let mut config = Config { + tls_root_certs: Some(TlsRootCerts::Webpki), + ..Default::default() + }; + partial_config(&mut config, "tls-root-certs").unwrap(); + assert_eq!(config.tls_root_certs, Some(TlsRootCerts::Webpki)); + } + + #[test] + fn test_partial_config_concurrency() { + let mut config = Config { + concurrency: ConcurrencyConfig { + solves: 10, + downloads: 20, + }, + ..Default::default() + }; + partial_config(&mut config, "concurrency").unwrap(); + assert_eq!(config.concurrency.solves, 10); + assert_eq!(config.concurrency.downloads, 20); + } + + #[test] + fn test_partial_config_concurrency_solves() { + let mut config = Config { + concurrency: ConcurrencyConfig { + solves: 10, + downloads: 20, + }, + ..Default::default() + }; + partial_config(&mut config, "concurrency.solves").unwrap(); + assert_eq!(config.concurrency.solves, 10); + + let mut toml = toml_edit::ser::to_string_pretty(&config).unwrap(); + prune_subkeys(&mut toml, "concurrency.solves", false).unwrap(); + assert!(toml.contains("solves = 10")); + assert!(!toml.contains("downloads")); + + let mut json = serde_json::to_string_pretty(&config).unwrap(); + prune_subkeys(&mut json, "concurrency.solves", true).unwrap(); + assert!(json.contains("\"solves\": 10")); + assert!(!json.contains("\"downloads\"")); + } + + #[test] + fn test_partial_config_concurrency_downloads() { + let mut config = Config { + concurrency: ConcurrencyConfig { + solves: 10, + downloads: 20, + }, + ..Default::default() + }; + partial_config(&mut config, "concurrency.downloads").unwrap(); + assert_eq!(config.concurrency.downloads, 20); + + let mut toml = toml_edit::ser::to_string_pretty(&config).unwrap(); + prune_subkeys(&mut toml, "concurrency.downloads", false).unwrap(); + assert!(toml.contains("downloads = 20")); + assert!(!toml.contains("solves")); + + let mut json = serde_json::to_string_pretty(&config).unwrap(); + prune_subkeys(&mut json, "concurrency.downloads", true).unwrap(); + assert!(json.contains("\"downloads\": 20")); + assert!(!json.contains("\"solves\"")); + } + + #[test] + fn test_partial_config_cache_subkeys() { + let mut config = Config { + cache: CacheConfig { + conda_packages: Some(PathBuf::from("/custom/cache/conda")), + build_tool_environments: Some(PathBuf::from("/custom/cache/build")), + ..Default::default() + }, + ..Default::default() + }; + partial_config(&mut config, "cache.conda-packages").unwrap(); + assert_eq!( + config.cache.conda_packages, + Some(PathBuf::from("/custom/cache/conda")) + ); + assert_eq!(config.cache.build_tool_environments, None); + } + + #[test] + fn test_partial_config_shell_subkeys() { + let mut config = Config { + shell: ShellConfig { + change_ps1: Some(false), + force_activate: Some(true), + ..Default::default() + }, + ..Default::default() + }; + partial_config(&mut config, "shell.change-ps1").unwrap(); + assert_eq!(config.shell.change_ps1, Some(false)); + assert_eq!(config.shell.force_activate, None); + } + + #[test] + fn test_partial_config_proxy_subkeys() { + let mut config = Config { + proxy_config: ProxyConfig { + http: Some(url::Url::parse("http://proxy.example.com").unwrap()), + https: Some(url::Url::parse("https://proxy.example.com").unwrap()), + ..Default::default() + }, + ..Default::default() + }; + partial_config(&mut config, "proxy-config.http").unwrap(); + assert_eq!( + config.proxy_config.http, + Some(url::Url::parse("http://proxy.example.com").unwrap()) + ); + assert_eq!(config.proxy_config.https, None); + } + + #[test] + fn test_partial_config_pypi_subkeys() { + let mut config = Config { + pypi_config: PyPIConfig { + index_url: Some(url::Url::parse("https://pypi.org/simple").unwrap()), + ..Default::default() + }, + ..Default::default() + }; + partial_config(&mut config, "pypi-config.index-url").unwrap(); + assert_eq!( + config.pypi_config.index_url, + Some(url::Url::parse("https://pypi.org/simple").unwrap()) + ); + assert_eq!(config.pypi_config.extra_index_urls, Vec::::new()); + } + + #[test] + fn test_partial_config_repodata_subkeys() { + let mut config = Config { + repodata_config: RepodataConfig { + default: RepodataChannelConfig { + disable_bzip2: Some(true), + disable_zstd: Some(true), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + partial_config(&mut config, "repodata-config.disable-bzip2").unwrap(); + assert_eq!(config.repodata_config.default.disable_bzip2, Some(true)); + assert_eq!(config.repodata_config.default.disable_zstd, None); + } + + #[test] + fn test_partial_config_index_subkeys() { + let mut config = Config { + index_config: IndexConfig { + default: IndexChannelConfig { + base_url: Some("https://index.example.com".to_string()), + write_zst: Some(true), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + partial_config(&mut config, "index-config.base-url").unwrap(); + assert_eq!( + config.index_config.default.base_url, + Some("https://index.example.com".to_string()) + ); + assert_eq!(config.index_config.default.write_zst, None); + } + + #[test] + fn test_partial_config_experimental_subkeys() { + let mut config = Config { + experimental: ExperimentalConfig { + conda_script: Some(true), + use_environment_activation_cache: Some(false), + }, + ..Default::default() + }; + partial_config(&mut config, "experimental.conda-script").unwrap(); + assert_eq!(config.experimental.conda_script, Some(true)); + assert_eq!(config.experimental.use_environment_activation_cache, None); + } + + #[test] + fn test_partial_config_s3_options() { + let mut config = Config::default(); + let s3_opts = pixi_config::S3Options { + endpoint_url: url::Url::parse("https://s3.example.com").unwrap(), + region: "us-west-2".to_string(), + force_path_style: true, + }; + config.s3_options.0.insert("mybucket".to_string(), s3_opts); + partial_config(&mut config, "s3-options").unwrap(); + assert!(config.s3_options.0.contains_key("mybucket")); + + let mut config2 = Config::default(); + config2.s3_options.0.insert( + "mybucket".to_string(), + pixi_config::S3Options { + endpoint_url: url::Url::parse("https://s3.example.com").unwrap(), + region: "us-west-2".to_string(), + force_path_style: true, + }, + ); + partial_config(&mut config2, "s3-options.mybucket").unwrap(); + assert!(config2.s3_options.0.contains_key("mybucket")); + + let mut config3 = Config::default(); + config3.s3_options.0.insert( + "mybucket".to_string(), + pixi_config::S3Options { + endpoint_url: url::Url::parse("https://s3.example.com").unwrap(), + region: "us-west-2".to_string(), + force_path_style: true, + }, + ); + partial_config(&mut config3, "s3-options.mybucket.region").unwrap(); + let mut toml = toml_edit::ser::to_string_pretty(&config3).unwrap(); + prune_subkeys(&mut toml, "s3-options.mybucket.region", false).unwrap(); + assert!(toml.contains("region = \"us-west-2\"")); + assert!(!toml.contains("endpoint-url")); + assert!(!toml.contains("force-path-style")); + } + + #[test] + fn test_partial_config_invalid_key_error() { + let mut config = Config::default(); + let err = partial_config(&mut config, "invalid-key").unwrap_err(); + let err_str = err.to_string(); + assert!(err_str.contains("key must be one of:")); + assert!(err_str.contains("tls-root-certs")); + assert!(err_str.contains("concurrency")); + assert!(err_str.contains("build")); + } + + #[test] + fn test_partial_config_all_supported_keys() { + let base_config = Config::default(); + let keys = base_config.get_keys(); + for key in keys { + let mut config = Config::default(); + let concrete_key = key.replace("", "testbucket"); + let result = partial_config(&mut config, &concrete_key); + assert!( + result.is_ok(), + "Key '{concrete_key}' failed partial_config: {:?}", + result.err() + ); + } + } +} diff --git a/crates/pixi_config/src/lib.rs b/crates/pixi_config/src/lib.rs index 1f717d1b8d..3d9b1bb974 100644 --- a/crates/pixi_config/src/lib.rs +++ b/crates/pixi_config/src/lib.rs @@ -1548,7 +1548,7 @@ pub struct ShellConfig { /// Whether to source completion scripts from the environment or not. #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] - source_completion_scripts: Option, + pub source_completion_scripts: Option, /// If set to true, pixi will set the PS1 environment variable to a custom /// value. @@ -1962,6 +1962,7 @@ impl Config { pub fn get_keys(&self) -> &[&str] { &[ "authentication-override-file", + "build", "cache", "cache.build-tool-environments", "cache.conda-packages", @@ -2263,6 +2264,13 @@ impl Config { "authentication-override-file" => { self.authentication_override_file = value.map(PathBuf::from); } + "build" => { + self.build = value + .map(|v| serde_json::de::from_str(&v)) + .transpose() + .into_diagnostic()? + .unwrap_or_default(); + } "tls-no-verify" => { self.tls_no_verify = value.map(|v| v.parse()).transpose().into_diagnostic()?; } @@ -3967,6 +3975,12 @@ UNUSED = "unused" .unwrap(); assert_eq!(config.pinning_strategy, Some(PinningStrategy::Semver)); + // Test build + config.set("build", Some(r#"{}"#.to_string())).unwrap(); + assert_eq!(config.build, BuildConfig::default()); + config.set("build", None).unwrap(); + assert_eq!(config.build, BuildConfig::default()); + config.set("unknown-key", None).unwrap_err(); }