diff --git a/rust/src/config/mod.rs b/rust/src/config/mod.rs index 1aa83d3b..a2e9fd76 100644 --- a/rust/src/config/mod.rs +++ b/rust/src/config/mod.rs @@ -101,9 +101,15 @@ impl Config { validate_dependency(&mut errors, &format!("dependencies[{i}].app[{j}]"), dep); } for (j, dep) in deps.feature.iter().enumerate() { - validate_dependency(&mut errors, &format!("dependencies[{i}].feature[{j}]"), dep); + require_min_len( + &mut errors, + &format!("dependencies[{i}].feature[{j}].name"), + &dep.name, + MIN_IDENT_LEN, + ); } } + validate_unique_feature_names(&mut errors, &self.dependencies); if let Some(extensions) = &self.extensions { for (i, ext) in extensions.embed.iter().enumerate() { @@ -278,6 +284,20 @@ fn validate_dependency(errors: &mut Vec, path: &str, dep: &DependencyCon } } +/// The API rejects a release that declares the same feature twice, across +/// every `[[dependencies]]` block. +fn validate_unique_feature_names(errors: &mut Vec, dependencies: &[DependenciesConfig]) { + let mut seen = std::collections::HashSet::new(); + for dep in dependencies.iter().flat_map(|d| &d.feature) { + if !seen.insert(dep.name.as_str()) { + errors.push(format!( + "dependencies feature {:?} is declared more than once", + dep.name + )); + } + } +} + fn validate_named_extension( errors: &mut Vec, path: &str, @@ -325,7 +345,7 @@ pub struct DependenciesConfig { #[serde(default)] pub app: Vec, #[serde(default)] - pub feature: Vec, + pub feature: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -334,6 +354,23 @@ pub struct DependencyConfig { pub version: Option, } +/// A feature the release declares, sent as `createRelease.featureDependencies`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeatureDependencyConfig { + pub name: String, + pub state: FeatureDependencyState, +} + +/// Mirrors the App Registry `FeatureDependencyState` enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum FeatureDependencyState { + Required, + Excluded, + Enabled, + Disabled, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExtensionsConfig { #[serde(default)] diff --git a/rust/src/config/tests.rs b/rust/src/config/tests.rs index 6ab8c843..94eb0de7 100644 --- a/rust/src/config/tests.rs +++ b/rust/src/config/tests.rs @@ -350,6 +350,35 @@ fn validate_rejects_subscription_without_events() { assert!(err.to_string().contains("events"), "{err}"); } +#[test] +fn validate_rejects_duplicate_feature_dependency_across_blocks() { + let mut config = valid_config(); + for _ in 0..2 { + config.dependencies.push(DependenciesConfig { + app: vec![], + feature: vec![FeatureDependencyConfig { + name: "automations".to_owned(), + state: FeatureDependencyState::Enabled, + }], + }); + } + let err = config.validate().expect_err("duplicate feature"); + assert!( + err.to_string() + .contains("feature \"automations\" is declared more than once"), + "{err}" + ); +} + +#[test] +fn parse_rejects_unknown_feature_dependency_state() { + let err = toml::from_str::( + r#"feature = [{ name = "automations", state = "ON" }]"#, + ) + .expect_err("unknown state"); + assert!(err.to_string().contains("unknown variant"), "{err}"); +} + #[test] fn validate_rejects_dependency_with_bad_semver() { let mut config = valid_config(); diff --git a/rust/src/platform/app/commands/release.rs b/rust/src/platform/app/commands/release.rs index c89a4a22..54370988 100644 --- a/rust/src/platform/app/commands/release.rs +++ b/rust/src/platform/app/commands/release.rs @@ -5,8 +5,8 @@ use std::path::Path; use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use platform_app_client::create_release::{ ApplicationActionCreateInput, ApplicationSettingCreateInput, - ApplicationSubscriptionCreateInput, ApplicationUiExtensionCreateInput, - MutationCreateReleaseInput, + ApplicationSubscriptionCreateInput, ApplicationUiExtensionCreateInput, FeatureDependencyInput, + FeatureDependencyState, MutationCreateReleaseInput, }; use serde_json::{Value, json}; @@ -148,6 +148,26 @@ fn build_settings( .collect() } +/// Map godaddy.toml `[[dependencies]]` feature entries to the release +/// `featureDependencies` input. +fn build_feature_dependencies(config: &crate::config::Config) -> Vec { + use crate::config::FeatureDependencyState as State; + config + .dependencies + .iter() + .flat_map(|d| &d.feature) + .map(|f| FeatureDependencyInput { + name: f.name.clone(), + state: match f.state { + State::Required => FeatureDependencyState::REQUIRED, + State::Excluded => FeatureDependencyState::EXCLUDED, + State::Enabled => FeatureDependencyState::ENABLED, + State::Disabled => FeatureDependencyState::DISABLED, + }, + }) + .collect() +} + /// Missing manifest returns `Ok(None)`; a manifest that exists but fails to /// read, parse, or validate is an error rather than a silent empty fallback. fn load_manifest(path: &Path) -> cli_engine::Result> { @@ -237,48 +257,63 @@ pub(super) fn command() -> RuntimeCommandSpec { let config_path = crate::config::config_path(Some(&ctx.middleware.env)); let manifest_dir = config_path.parent().unwrap_or_else(|| Path::new("")); - // Pulls actions/subscriptions/uiExtensions/settings from godaddy.toml; see load_manifest. - let (actions, subscriptions, ui_extensions, settings, native_extension) = - match load_manifest(&config_path)? { - Some(config) => { - let actions: Vec = config - .actions - .iter() - .map(|a| ApplicationActionCreateInput { - name: a.name.clone(), - url: a.url.clone(), - }) - .collect(); - let subscriptions: Vec = config - .subscriptions - .as_ref() - .map(|s| { - s.webhook - .iter() - .map(|w| ApplicationSubscriptionCreateInput { - name: w.name.clone(), - events: w.events.clone(), - url: w.url.clone(), - }) - .collect() - }) - .unwrap_or_default(); - let ui_extensions = build_ui_extensions(&config)?; - let settings = build_settings(&config, manifest_dir)?; - let native_extension = native_extension::native_extension_draft_if_visible( - &config, - &ctx.middleware.flag_policy, - ); - ( - actions, - subscriptions, - ui_extensions, - settings, - native_extension, - ) - } - None => (Vec::new(), Vec::new(), Vec::new(), Vec::new(), None), - }; + // Pulls actions/subscriptions/uiExtensions/settings/feature deps from godaddy.toml; see load_manifest. + let ( + actions, + subscriptions, + ui_extensions, + settings, + feature_dependencies, + native_extension, + ) = match load_manifest(&config_path)? { + Some(config) => { + let actions: Vec = config + .actions + .iter() + .map(|a| ApplicationActionCreateInput { + name: a.name.clone(), + url: a.url.clone(), + }) + .collect(); + let subscriptions: Vec = config + .subscriptions + .as_ref() + .map(|s| { + s.webhook + .iter() + .map(|w| ApplicationSubscriptionCreateInput { + name: w.name.clone(), + events: w.events.clone(), + url: w.url.clone(), + }) + .collect() + }) + .unwrap_or_default(); + let ui_extensions = build_ui_extensions(&config)?; + let settings = build_settings(&config, manifest_dir)?; + let feature_dependencies = build_feature_dependencies(&config); + let native_extension = native_extension::native_extension_draft_if_visible( + &config, + &ctx.middleware.flag_policy, + ); + ( + actions, + subscriptions, + ui_extensions, + settings, + feature_dependencies, + native_extension, + ) + } + None => ( + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ), + }; let mut input = MutationCreateReleaseInput { application_id: app_id, @@ -288,7 +323,7 @@ pub(super) fn command() -> RuntimeCommandSpec { subscriptions: Some(subscriptions), settings: Some(settings), application_dependencies: None, - feature_dependencies: None, + feature_dependencies: Some(feature_dependencies), mcp_servers: None, native_extensions: None, ui_extensions: None, @@ -761,6 +796,48 @@ authorization_scopes = [] ); } + #[test] + fn build_feature_dependencies_maps_manifest_entries() { + use platform_app_client::create_release::FeatureDependencyState; + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + std::fs::write( + &path, + r#" +name = "my-app" +client_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" +version = "1.0.0" +url = "https://example.com" +proxy_url = "https://example.com/proxy" +authorization_scopes = ["openid"] + +[[dependencies]] +feature = [ + { name = "automations", state = "ENABLED" }, + { name = "payouts", state = "EXCLUDED" }, +] +"#, + ) + .expect("write manifest"); + let config = super::load_manifest(&path) + .expect("manifest loads") + .expect("manifest exists"); + + let deps = super::build_feature_dependencies(&config); + + let mapped: Vec<_> = deps.iter().map(|d| (d.name.as_str(), &d.state)).collect(); + assert!( + matches!( + mapped.as_slice(), + [ + ("automations", FeatureDependencyState::ENABLED), + ("payouts", FeatureDependencyState::EXCLUDED), + ] + ), + "unexpected mapping: {deps:?}" + ); + } + #[test] fn ui_extension_entry_rejects_multiple_targets() { use crate::config::ExtensionTarget;