diff --git a/Cargo.lock b/Cargo.lock index cc9cee00..29770ba1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,6 +401,7 @@ dependencies = [ "dialoguer", "fs_extra", "http", + "mergeme", "regex", "reqwest", "semver", @@ -2781,6 +2782,26 @@ dependencies = [ "libc", ] +[[package]] +name = "mergeme" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20db7ce47a7915de27ab0ec2a7f6b9b4c6ce6a754ab9d663a38c77b1b49fe08c" +dependencies = [ + "mergeme_derive", +] + +[[package]] +name = "mergeme_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c81c9dc10255e9445455f0bc5bf1fdab1d5610e568253bc32b6de4a09d90c4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.17" diff --git a/Cargo.toml b/Cargo.toml index af01d08b..6df3f5ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,8 @@ toml_edit = { version = "0.22.22", default-features = false, features = [ "parse", "serde", ] } +# Merging CLI configuration +mergeme = "0.2.0" # Logging crates tracing = "0.1.41" diff --git a/src/build/mod.rs b/src/build/mod.rs index ce17cd0b..3b92d26e 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -25,9 +25,8 @@ pub fn build(args: &mut BuildArgs) -> anyhow::Result<()> { args.profile(), )?; - let config = CliConfig::for_package( - &metadata, - bin_target.package, + let config = CliConfig::from_metadata( + &bin_target.package.metadata, args.is_web(), args.is_release(), )?; diff --git a/src/config.rs b/src/config.rs index 6db9daf0..a2c3f969 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,52 +1,85 @@ //! Configuration used by the `bevy_cli`, defined in `Cargo.toml` under `package.metadata.bevy_cli`. use std::fmt::Display; -use anyhow::{Context, bail}; -use serde::Serialize; -use serde_json::{Map, Value}; -use tracing::warn; - -use crate::external_cli::cargo::metadata::{Metadata, Package}; +use mergeme::Merge; +use serde::{Deserialize, Serialize}; +use serde_json::Value; /// Configuration for the `bevy_cli`. /// -/// Allows customizing: -/// - Target platform -/// - Enabled features -/// - Whether to enable default features -/// - Additional Rust compiler flags -#[derive(Default, Debug, Clone, PartialEq, Serialize)] +/// [`PartialCliConfig`]s are intended to be deserialized from `[package.metadata.bevy_cli]` and +/// merged into this struct. +#[derive(Merge, PartialEq, Serialize, Debug)] +#[partial( + PartialCliConfig, + derive(Deserialize), + serde(rename_all = "kebab-case") +)] #[serde(rename_all = "kebab-case")] pub struct CliConfig { /// The platform to target with the build. target: Option, + /// Additional features that should be enabled. + /// + /// Features are additive when merged. + #[strategy(merge)] features: Vec, + /// Whether to use default features. - default_features: Option, + default_features: bool, + /// Additional flags for `rustc` + /// + /// Rust flags are additive when merged. + #[strategy(merge)] rustflags: Vec, + /// Use `wasm-opt` to optimize wasm binaries. wasm_opt: Option, } impl CliConfig { - /// Returns `true` if the config doesn't change the defaults. + pub fn from_metadata( + package_metadata: &Value, + is_web: bool, + is_release: bool, + ) -> anyhow::Result { + let profile = if is_release { "release" } else { "dev" }; + let target = if is_web { "web" } else { "native" }; + + // The base configuration that all partial config gets merged into. + let mut config = Self::default(); + + // Return the `[package.metadata.bevy_cli]` table for the current package, if it exists. + let cli_metadata = package_metadata.get("bevy_cli"); + let profile_metadata = cli_metadata.and_then(|m| m.get(profile)); + let target_metadata = cli_metadata.and_then(|m| m.get(target)); + let target_profile_metadata = target_metadata.and_then(|m| m.get(profile)); + + // Deserialise each `Value` into a `PartialCliConfig`, then merge it into the default. See + // + // for more info. + for metadata in [ + cli_metadata, + profile_metadata, + target_metadata, + target_profile_metadata, + ] { + let Some(metadata) = metadata else { + continue; + }; + + // Deserialize metadata into a `PartialCliConfig`, then merge it with the base. + config.merge_in_place(serde_json::from_value(metadata.clone())?); + } + + Ok(config) + } + + /// Returns true if this config is equivalent to that returned by [`CliConfig::default()`]. pub fn is_default(&self) -> bool { - // Using destructuring to ensure that all fields are considered - let Self { - target, - features, - default_features, - rustflags, - wasm_opt, - } = self; - - target.is_none() - && features.is_empty() - && default_features.is_none() - && rustflags.is_empty() - && wasm_opt.is_none() + *self == Self::default() } /// The platform to target with the build. @@ -54,24 +87,26 @@ impl CliConfig { self.target.as_deref() } - /// Whether to enable default features. - /// - /// Defaults to `true` if not configured otherwise. - pub fn default_features(&self) -> bool { - self.default_features.unwrap_or(true) - } - /// The features enabled in the config. pub fn features(&self) -> &[String] { &self.features } - /// The rustflags enabled in the config + /// Whether to enable default features. + pub fn default_features(&self) -> bool { + self.default_features + } + + /// The rustflags enabled in the config. + /// + /// This is automatically formatted so that it may be passed to the `RUSTFLAGS` environmental + /// variable. pub fn rustflags(&self) -> Option { if self.rustflags.is_empty() { return None; } - Some(self.rustflags.clone().join(" ")) + + Some(self.rustflags.join(" ")) } /// Whether to use `wasm-opt`. @@ -79,209 +114,37 @@ impl CliConfig { pub fn wasm_opt(&self) -> Option { self.wasm_opt } - - /// Determine the Bevy CLI config as defined in the given package. - pub fn for_package( - metadata: &Metadata, - package: &Package, - is_web: bool, - is_release: bool, - ) -> anyhow::Result { - let Some(package_metadata) = metadata.packages.iter().find_map(|cur_package| { - if package == cur_package { - Some(cur_package.metadata.clone()) - } else { - None - } - }) else { - return Ok(Self::default()); - }; - - let base_metadata = package_metadata.get("bevy_cli"); - Self::merged_from_metadata(base_metadata, is_web, is_release) - } - - /// Build a config from the `package.metadata.bevy_cli` table. - /// - /// It is merged from the platform- and profile-specific configurations. - fn merged_from_metadata( - cli_metadata: Option<&Value>, - is_web: bool, - is_release: bool, - ) -> anyhow::Result { - let profile = if is_release { "release" } else { "dev" }; - let platform = if is_web { "web" } else { "native" }; - - let profile_metadata = cli_metadata.and_then(|metadata| metadata.get(profile)); - let platform_metadata = cli_metadata.and_then(|metadata| metadata.get(platform)); - let platform_profile_metadata = - platform_metadata.and_then(|metadata| metadata.get(profile)); - - // Start with the base config - let config = Self::from_specific_metadata(cli_metadata) - .context("failed to parse package.metadata.bevy_cli")? - // Add the profile-specific config - .overwrite( - &Self::from_specific_metadata(profile_metadata).context(format!( - "failed to parse package.metadata.bevy_cli.{profile}" - ))?, - ) - // Then the platform-specific config - .overwrite( - &Self::from_specific_metadata(platform_metadata).context(format!( - "failed to parse package.metadata.bevy_cli.{platform}" - ))?, - ) - // Finally, the platform-profile combination - .overwrite( - &Self::from_specific_metadata(platform_profile_metadata).context(format!( - "failed to parse package.metadata.bevy_cli.{platform}.{profile}" - ))?, - ); - - Ok(config) - } - - /// Build a single config for a specific platform- or profile-specific configuration. - fn from_specific_metadata(metadata: Option<&Value>) -> anyhow::Result { - let Some(metadata) = metadata else { - return Ok(Self::default()); - }; - let Value::Object(metadata) = metadata else { - bail!("Bevy CLI config must be a table"); - }; - - Ok(Self { - target: extract_target(metadata)?, - features: extract_features(metadata)?, - default_features: extract_default_features(metadata)?, - rustflags: extract_rustflags(metadata)?, - wasm_opt: extract_use_wasm_opt(metadata)?, - }) - } - - /// Merge another config into this one. - /// - /// The other config takes precedence, - /// it's values overwrite the current values if one has to be chosen. - pub fn overwrite(mut self, with: &Self) -> Self { - self.target = with.target.clone().or(self.target); - self.default_features = with.default_features.or(self.default_features); - - self.wasm_opt = with.wasm_opt.or(self.wasm_opt); - - // Features and Rustflags are additive - self.features.extend(with.features.iter().cloned()); - self.rustflags.extend(with.rustflags.iter().cloned()); - - self - } -} - -/// Try to extract the target platform from a metadata map for the CLI. -fn extract_target(cli_metadata: &Map) -> anyhow::Result> { - let Some(target) = cli_metadata.get("target") else { - return Ok(None); - }; - - match target { - Value::String(target) => Ok(Some(target).cloned()), - Value::Null => Ok(None), - _ => bail!("target must be a string"), - } -} - -/// Try to extract the list of features from a metadata map for the CLI. -fn extract_features(cli_metadata: &Map) -> anyhow::Result> { - let Some(features) = cli_metadata.get("features") else { - return Ok(Vec::new()); - }; - - match features { - Value::Array(features) => features - .iter() - .map(|value| { - value - .as_str() - .map(|str| str.to_string()) - .ok_or_else(|| anyhow::anyhow!("each feature must be a string")) - }) - .collect(), - Value::Null => Ok(Vec::new()), - _ => bail!("features must be an array"), - } } -/// Try to extract whether default-features are enabled from a metadata map for the CLI. -fn extract_default_features(cli_metadata: &Map) -> anyhow::Result> { - if let Some(default_features) = cli_metadata.get("default-features") { - match default_features { - Value::Bool(default_features) => Ok(Some(default_features).copied()), - Value::Null => Ok(None), - _ => bail!("default-features must be a boolean"), - } - } else if let Some(default_features) = cli_metadata.get("default_features") { - warn!( - "`default_features` has been renamed to `default-features` to align with Cargo's naming conventions." - ); - match default_features { - Value::Bool(default_features) => Ok(Some(default_features).copied()), - Value::Null => Ok(None), - _ => bail!("default_features must be a boolean"), +impl Default for CliConfig { + fn default() -> Self { + Self { + target: None, + features: Vec::new(), + default_features: true, + rustflags: Vec::new(), + wasm_opt: None, } - } else { - return Ok(None); - } -} - -fn extract_rustflags(cli_metadata: &Map) -> anyhow::Result> { - let Some(rustflags) = cli_metadata.get("rustflags") else { - return Ok(Vec::new()); - }; - - match rustflags { - Value::Array(rustflags) => rustflags - .iter() - .map(|value| { - value - .as_str() - .map(std::string::ToString::to_string) - .ok_or_else(|| anyhow::anyhow!("each rustflag must be a string")) - }) - .collect(), - Value::String(rustflag) => Ok(vec![rustflag.clone()]), - Value::Null => Ok(Vec::new()), - _ => bail!("rustflags must be an array or string"), - } -} - -fn extract_use_wasm_opt(cli_metadata: &Map) -> anyhow::Result> { - if let Some(use_wasm_opt) = cli_metadata.get("wasm-opt") { - match use_wasm_opt { - Value::Bool(use_wasm_opt) => Ok(Some(use_wasm_opt).copied()), - Value::Null => Ok(None), - _ => bail!("wasm-opt must be a boolean"), - } - } else { - Ok(None) } } impl Display for CliConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let document = toml_edit::ser::to_document(self).map_err(|_| std::fmt::Error)?; + // Serialize this struct into TOML. + let document = toml_edit::ser::to_string(self).map_err(|_| std::fmt::Error)?; + write!( f, "{}", document - .to_string() - // Remove trailing newline + // Remove trailing newline. .trim_end() .lines() - // Align lines with the debug message + // Align lines with the debug message. .map(|line| format!(" {line}")) - .collect::>() - .join("\n") + // Join all lines together with a newline in between. + .reduce(|acc, line| acc + "\n" + line.as_ref()) + .unwrap_or(String::new()) ) } } @@ -290,7 +153,7 @@ impl Display for CliConfig { mod tests { use super::*; - mod merged_from_metadata { + mod from_metadata { use serde_json::json; use super::*; @@ -298,23 +161,25 @@ mod tests { #[test] fn should_return_merged_config_for_web_dev() -> anyhow::Result<()> { let metadata = json!({ - "rustflags": ["-C opt-level=2"], - "features": ["base"], - "dev": { - "features": ["dev"], - }, - "web": { - "features": ["web"], - "default-features": false, + "bevy_cli": { + "rustflags": ["-C opt-level=2"], + "features": ["base"], "dev": { - "features": ["web-dev"], - "rustflags": ["--cfg","getrandom_backend=\"wasm_js\""] + "features": ["dev"], }, - } + "web": { + "features": ["web"], + "default-features": false, + "dev": { + "features": ["web-dev"], + "rustflags": ["--cfg","getrandom_backend=\"wasm_js\""], + }, + }, + }, }); assert_eq!( - CliConfig::merged_from_metadata(Some(&metadata), true, false)?, + CliConfig::from_metadata(&metadata, true, false)?, CliConfig { target: None, features: vec![ @@ -323,7 +188,7 @@ mod tests { "web".to_owned(), "web-dev".to_owned() ], - default_features: Some(false), + default_features: false, rustflags: vec![ "-C opt-level=2".to_string(), "--cfg".to_string(), @@ -332,34 +197,37 @@ mod tests { wasm_opt: None } ); + Ok(()) } #[test] fn should_return_merged_config_for_native_release() -> anyhow::Result<()> { let metadata = json!({ - "rustflags": ["-C opt-level=2"], - "features": ["base"], - "release": { - "features": ["release"], - }, - "native": { - "features": ["native"], - "default-features": false, + "bevy_cli": { + "rustflags": ["-C opt-level=2"], + "features": ["base"], "release": { - "features": ["native-release"], - "rustflags": ["-C debuginfo=1"] - } + "features": ["release"], + }, + "native": { + "features": ["native"], + "default-features": false, + "release": { + "features": ["native-release"], + "rustflags": ["-C debuginfo=1"], + }, + }, + "web": { + "features": ["web"], + "default-features": false, + "rustflags": ["--cfg","getrandom_backend=\"wasm_js\""], + }, }, - "web": { - "features": ["web"], - "default-features": false, - "rustflags": ["--cfg","getrandom_backend=\"wasm_js\""] - } }); assert_eq!( - CliConfig::merged_from_metadata(Some(&metadata), false, true)?, + CliConfig::from_metadata(&metadata, false, true)?, CliConfig { target: None, features: vec![ @@ -368,37 +236,40 @@ mod tests { "native".to_owned(), "native-release".to_owned() ], - default_features: Some(false), + default_features: false, rustflags: vec!["-C opt-level=2".to_string(), "-C debuginfo=1".to_string()], wasm_opt: None } ); + Ok(()) } #[test] fn should_return_merged_config_for_native_dev() -> anyhow::Result<()> { let metadata = json!({ - "features": ["native-dev"], - "dev": { - "features": [ - "bevy/dynamic_linking", - "bevy/bevy_dev_tools", - "bevy/bevy_ui_debug" - ], - "default-features": true, - }, - "web": { - "features": ["web"], - "default-features": false, + "bevy_cli": { + "features": ["native-dev"], "dev": { - "features": ["web-dev"], - } - } + "features": [ + "bevy/dynamic_linking", + "bevy/bevy_dev_tools", + "bevy/bevy_ui_debug", + ], + "default-features": true, + }, + "web": { + "features": ["web"], + "default-features": false, + "dev": { + "features": ["web-dev"], + }, + }, + }, }); assert_eq!( - CliConfig::merged_from_metadata(Some(&metadata), false, false)?, + CliConfig::from_metadata(&metadata, false, false)?, CliConfig { target: None, features: vec![ @@ -407,11 +278,12 @@ mod tests { "bevy/bevy_dev_tools".to_owned(), "bevy/bevy_ui_debug".to_owned() ], - default_features: Some(true), + default_features: true, rustflags: Vec::new(), wasm_opt: None } ); + Ok(()) } @@ -420,113 +292,47 @@ mod tests { let metadata = json!({}); assert_eq!( - CliConfig::merged_from_metadata(Some(&metadata), true, false)?, + CliConfig::from_metadata(&metadata, true, false)?, CliConfig::default() ); + Ok(()) } #[test] fn should_ignore_unrelated_configs() -> anyhow::Result<()> { let metadata = json!({ - "features": ["base"], - "dev": { - "rustflags": ["-C opt-level=2"], - "features": ["dev"], - "default-features": true, - }, - "web": { - "features": ["web"], - "default-features": false, - "rustflags": ["--cfg","getrandom_backend=\"wasm_js\""], + "bevy_cli": { + "features": ["base"], "dev": { - "rustflags": ["-C debuginfo=1"], - "features": ["web-dev"], - } - } + "rustflags": ["-C opt-level=2"], + "features": ["dev"], + "default-features": true, + }, + "web": { + "features": ["web"], + "default-features": false, + "rustflags": ["--cfg","getrandom_backend=\"wasm_js\""], + "dev": { + "rustflags": ["-C debuginfo=1"], + "features": ["web-dev"], + }, + }, + }, }); assert_eq!( - CliConfig::merged_from_metadata(Some(&metadata), false, true)?, + CliConfig::from_metadata(&metadata, false, true)?, CliConfig { target: None, features: vec!["base".to_owned(),], - default_features: None, + default_features: true, rustflags: Vec::new(), wasm_opt: None } ); - Ok(()) - } - } - mod extract_target { - use serde_json::Map; - - use super::*; - - #[test] - fn should_return_none_if_no_target_specified() -> anyhow::Result<()> { - let cli_metadata = Map::new(); - assert_eq!(extract_target(&cli_metadata)?, None); - Ok(()) - } - - #[test] - fn should_return_target_if_specified() -> anyhow::Result<()> { - let mut cli_metadata = Map::new(); - cli_metadata.insert("target".to_owned(), "wasm32v1-none".into()); - assert_eq!( - extract_target(&cli_metadata)?, - Some("wasm32v1-none".to_string()) - ); Ok(()) } - - #[test] - fn should_return_error_if_target_is_not_a_string() { - let mut cli_metadata = Map::new(); - cli_metadata.insert("target".to_string(), 32.into()); - assert!(extract_target(&cli_metadata).is_err()); - } - } - - mod extract_features { - use serde_json::Map; - - use super::*; - - #[test] - fn should_return_empty_vec_if_no_features_specified() -> anyhow::Result<()> { - let cli_metadata = Map::new(); - assert_eq!(extract_features(&cli_metadata)?, Vec::::new()); - Ok(()) - } - - #[test] - fn should_return_features_if_listed() -> anyhow::Result<()> { - let mut cli_metadata = Map::new(); - cli_metadata.insert("features".to_owned(), vec!["dev", "web"].into()); - assert_eq!( - extract_features(&cli_metadata)?, - vec!["dev".to_owned(), "web".to_owned()] - ); - Ok(()) - } - - #[test] - fn should_return_error_if_one_feature_is_not_a_string() { - let mut cli_metadata = Map::new(); - cli_metadata.insert( - "features".to_string(), - vec![ - Value::String("dev".to_owned()), - Value::Bool(false), - Value::Null, - ] - .into(), - ); - assert!(extract_features(&cli_metadata).is_err()); - } } } diff --git a/src/run/mod.rs b/src/run/mod.rs index cc92cade..0ba49a9c 100644 --- a/src/run/mod.rs +++ b/src/run/mod.rs @@ -25,9 +25,8 @@ pub fn run(args: &mut RunArgs) -> anyhow::Result<()> { args.profile(), )?; - let config = CliConfig::for_package( - &metadata, - bin_target.package, + let config = CliConfig::from_metadata( + &bin_target.package.metadata, args.is_web(), args.is_release(), )?;