diff --git a/Cargo.lock b/Cargo.lock index 808a88e9..492fd27d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1546,9 +1546,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ "atomic", + "parking_lot", "pear", "serde", "serde_json", + "tempfile", "uncased", "version_check", ] diff --git a/Cargo.toml b/Cargo.toml index c8fd403a..6c127ad8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ http-body-util = { version = "0.1.3", default-features = false } rcgen = { version = "0.13", default-features = false, features = ["crypto", "ring", "pem"] } tempfile = { version = "3.0", default-features = false } tokio = { version = "1.45.1", default-features = false, features = ["full"] } +figment = { version = "0.10.19", default-features = false, features = ["json", "env", "test"] } [package.metadata.binstall] pkg-url = "{ repo }/releases/download/{name}-v{version}/wash-{ target }{ binary-ext }" diff --git a/crates/wash/src/cli/mod.rs b/crates/wash/src/cli/mod.rs index b63c81f9..445fdeec 100644 --- a/crates/wash/src/cli/mod.rs +++ b/crates/wash/src/cli/mod.rs @@ -647,3 +647,61 @@ impl CliContext { Ok(()) } } + +#[cfg(test)] +pub mod test { + use super::*; + use tempfile::{TempDir, tempdir}; + + #[derive(Debug)] + struct TestAppStrategy { + home: TempDir, + } + + impl TestAppStrategy { + fn new() -> anyhow::Result { + Ok(TestAppStrategy { home: tempdir()? }) + } + } + + impl DirectoryStrategy for TestAppStrategy { + fn home_dir(&self) -> &Path { + self.home.path() + } + fn config_dir(&self) -> PathBuf { + self.home.path().join("config") + } + fn data_dir(&self) -> PathBuf { + self.home.path().join("data") + } + fn cache_dir(&self) -> PathBuf { + self.home.path().join("cache") + } + fn state_dir(&self) -> Option { + Some(self.home.path().join("state")) + } + fn runtime_dir(&self) -> Option { + Some(self.home.path().join("runtime")) + } + } + + pub async fn create_test_cli_context() -> anyhow::Result { + let app_strategy = Arc::new(TestAppStrategy::new()?); + let (plugin_runtime, thread) = new_runtime() + .await + .context("failed to create wasmcloud runtime")?; + + let plugin_manager = PluginManager::initialize(&plugin_runtime, app_strategy.data_dir()) + .await + .context("failed to initialize plugin manager")?; + + Ok(CliContext { + app_strategy, + runtime: plugin_runtime, + runtime_thread: Arc::new(thread), + plugin_manager: Arc::new(plugin_manager), + background_processes: Arc::default(), + non_interactive: true, + }) + } +} diff --git a/crates/wash/src/component_build.rs b/crates/wash/src/component_build.rs index 867d34df..bdfdeb3f 100644 --- a/crates/wash/src/component_build.rs +++ b/crates/wash/src/component_build.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use std::str::FromStr; /// Build configuration for different language toolchains -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct BuildConfig { /// Rust-specific build configuration #[serde(skip_serializing_if = "Option::is_none")] @@ -26,7 +26,7 @@ pub struct BuildConfig { } /// Types of projects that can be built -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ProjectType { /// Rust project (Cargo.toml found) @@ -40,7 +40,7 @@ pub enum ProjectType { } /// Rust-specific build configuration with explicit defaults -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RustBuildConfig { /// Custom build command that overrides all other Rust build settings /// When specified, all other Rust build flags are ignored @@ -155,8 +155,8 @@ impl FromStr for TinyGoGarbageCollector { } } -/// TinyGo-specific build configuration with explicit defaults -#[derive(Debug, Clone, Serialize, Deserialize)] +/// TinyGo-specific build configuration with explicit defaults +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TinyGoBuildConfig { /// Custom build command that overrides all other TinyGo build settings /// When specified, all other TinyGo build flags are ignored @@ -259,7 +259,7 @@ fn default_tinygo_no_debug() -> bool { } /// TypeScript-specific build configuration with explicit defaults -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TypeScriptBuildConfig { /// Custom build command that overrides all other TypeScript build settings /// When specified, all other TypeScript build flags are ignored diff --git a/crates/wash/src/config.rs b/crates/wash/src/config.rs index feac7ac0..8e6f090f 100644 --- a/crates/wash/src/config.rs +++ b/crates/wash/src/config.rs @@ -29,7 +29,7 @@ pub const PROJECT_CONFIG_DIR: &str = ".wash"; /// (typically `~/.config/wash/config.json`), while the "local" project configuration /// is stored in the project's `.wash/config.json` file. This allows for both reasonable /// global defaults and project-specific overrides. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct Config { /// Build configuration for different project types (default: empty/optional) #[serde(skip_serializing_if = "Option::is_none")] @@ -148,7 +148,7 @@ where } // Environment variables with WASH_ prefix - figment = figment.merge(Env::prefixed("WASH_")); + figment = figment.merge(Env::prefixed("WASH_").split("__")); // TODO(#16): There's more testing to be done here to ensure that CLI args can override existing // config without replacing present values with empty values. @@ -277,3 +277,159 @@ pub async fn generate_default_config( info!(config_path = %path.display(), "Generated default configuration"); Ok(()) } + +#[cfg(test)] +mod test { + use super::*; + use crate::cli::test::create_test_cli_context; + + use figment::Jail; + use tempfile::tempdir; + + #[tokio::test] + async fn test_load_config_only_defaults() -> anyhow::Result<()> { + let ctx = create_test_cli_context().await?; + let config = load_config(&ctx.config_path(), None, None::)?; + assert_eq!(config, Config::default()); + Ok(()) + } + + #[tokio::test] + async fn test_load_config_with_global_config() -> anyhow::Result<()> { + let ctx = create_test_cli_context().await?; + + let global_config = Config::default_with_templates(); + save_config(&global_config, &ctx.config_path()).await?; + + let config = load_config(&ctx.config_path(), None, None::)?; + assert_eq!(config, global_config); + Ok(()) + } + + #[tokio::test] + async fn test_load_config_with_local_config() -> anyhow::Result<()> { + let ctx = create_test_cli_context().await?; + + let global_config = Config::default_with_templates(); + save_config(&global_config, &ctx.config_path()).await?; + + let project = tempdir()?; + let project_dir = project.path(); + let local_config_file = project_dir.join(PROJECT_CONFIG_DIR).join(CONFIG_FILE_NAME); + let mut local_config = Config::default_with_templates(); + local_config.build = Some(BuildConfig { + rust: Some(RustBuildConfig { + release: true, + ..Default::default() + }), + ..Default::default() + }); // Should override global + local_config.templates = Vec::new(); // should take templates from global + save_config(&local_config, &local_config_file).await?; + + let config = load_config(&ctx.config_path(), Some(&project_dir), None::)?; + assert_eq!(config.wit, Config::default().wit); + assert_eq!(config.templates, global_config.templates); + assert_eq!(config.build, local_config.build); + Ok(()) + } + + #[tokio::test] + async fn test_load_config_with_env_vars() -> anyhow::Result<()> { + let ctx = create_test_cli_context().await?; + + let project = tempdir()?; + let project_dir = project.path(); + let local_config_file = project_dir.join(PROJECT_CONFIG_DIR).join(CONFIG_FILE_NAME); + let mut local_config = Config::default_with_templates(); + local_config.build = Some(BuildConfig { + rust: Some(RustBuildConfig { + release: true, + ..Default::default() + }), + ..Default::default() + }); + save_config(&local_config, &local_config_file).await?; + + Jail::expect_with(|jail| { + // Should override whatever was set in local configuration + jail.set_env("WASH_BUILD__RUST__RELEASE", "false"); + // Using double underscore as delimiter allows to use multi-words for configuration via + // env variables + jail.set_env("WASH_BUILD__RUST__CUSTOM_COMMAND", "[cargo,build]"); + + let config = load_config(&ctx.config_path(), Some(&project_dir), None::) + .expect("configuration should be loadable"); + + let rust_build_config = config + .clone() + .build + .ok_or("build config should contain information")? + .rust + .ok_or("rust build config should contain information")?; + + assert_eq!(rust_build_config.release, false); + + assert_eq!( + rust_build_config.custom_command, + Some(vec!["cargo".into(), "build".into()]) + ); + + Ok(()) + }); + Ok(()) + } + + #[tokio::test] + async fn test_load_config_with_cli_args() -> anyhow::Result<()> { + let ctx = create_test_cli_context().await?; + + let some_path = "/this/is/some/path"; + let custom_command = vec!["cargo".into(), "component".into(), "bindings".into()]; + let mut cli_config = Config::default_with_templates(); + cli_config.build = Some(BuildConfig { + component_path: Some(some_path.into()), + rust: Some(RustBuildConfig { + custom_command: Some(custom_command.clone()), + ..Default::default() + }), + ..Default::default() + }); + + Jail::expect_with(|jail| { + // Should be irrelevant, as is overwritten by CLI configuration. + jail.set_env("WASH_BUILD__RUST__CUSTOM_COMMAND", "[cargo,build]"); + + let config = load_config(&ctx.config_path(), None, Some(cli_config)) + .expect("configuration should be loadable"); + + let build_config = config + .clone() + .build + .ok_or("build config should contain information")?; + + assert_eq!( + build_config + .clone() + .rust + .ok_or("rust build config should contain information")? + .custom_command, + Some(custom_command) + ); + + assert_eq!(build_config.clone().component_path, Some(some_path.into())); + + assert_eq!( + build_config + .clone() + .rust + .ok_or("rust build config should contain information")? + .release, + RustBuildConfig::default().release + ); + + Ok(()) + }); + Ok(()) + } +} diff --git a/crates/wash/src/new.rs b/crates/wash/src/new.rs index 1cea622e..7695c96d 100644 --- a/crates/wash/src/new.rs +++ b/crates/wash/src/new.rs @@ -18,7 +18,7 @@ pub enum TemplateLanguage { Other(String), } -#[derive(Default, Debug, Clone, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct NewTemplate { pub name: String, #[serde(default)] diff --git a/crates/wash/src/wit.rs b/crates/wash/src/wit.rs index 2d30ae49..965dd7cc 100644 --- a/crates/wash/src/wit.rs +++ b/crates/wash/src/wit.rs @@ -24,7 +24,7 @@ pub const LOCK_FILE_NAME: &str = "wasmcloud.lock"; pub const WKG_LOCK_FILE_NAME: &str = "wkg.lock"; /// Configuration for WIT dependency management -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct WitConfig { /// Registries for WIT package fetching (default: wasm.pkg registry) #[serde(default = "default_wit_registries")] @@ -50,7 +50,7 @@ fn default_wit_registries() -> Vec { } /// WIT registry configuration -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WitRegistry { /// Registry URL pub url: String,