diff --git a/components/search/src/configuration_types.rs b/components/search/src/configuration_types.rs index cc0f837d6f..0ff2cb2c93 100644 --- a/components/search/src/configuration_types.rs +++ b/components/search/src/configuration_types.rs @@ -5,7 +5,7 @@ //! This module defines the structures that we use for serde_json to parse //! the search configuration. -use crate::{SearchEngineClassification, SearchUrlParam}; +use crate::{SearchApplicationName, SearchEngineClassification, SearchUrlParam}; use serde::Deserialize; /// The list of possible submission methods for search engine urls. @@ -91,12 +91,71 @@ pub(crate) struct JSONEngineBase { pub urls: JSONEngineUrls, } +/// Specifies details of possible user environments that the engine or variant +/// applies to. +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct JSONVariantEnvironment { + /// Indicates that this section applies to all regions and locales. May be + /// modified by excludedRegions/excludedLocales. + pub all_regions_and_locales: Option, + + /// A vector of locales that this section should be excluded from. 'default' + /// will apply to situations where we have not been able to detect the user's + /// locale. + pub excluded_locales: Option>, + + /// A vector of regions that this section should be excluded from. 'default' + /// will apply to situations where we have not been able to detect the user's + /// region. + pub excluded_regions: Option>, + + /// A vector of locales that this section applies to. 'default' will apply + /// to situations where we have not been able to detect the user's locale. + pub locales: Option>, + + /// A vector of regions that this section applies to. 'default' will apply + /// to situations where we have not been able to detect the user's region. + pub regions: Option>, + + /// A vector of distribution identifiers that this section applies to. + pub distributions: Option>, + + /// A vector of distributions that this section should be excluded from. + pub excluded_distributions: Option>, + + /// A vector of applications that this applies to. + pub applications: Option>, + // TODO: Implement these. + // pub channels: Option, + // pub experiment: Option, + // pub min_version: Option, + // pub max_version: Option, + // pub device_type: Option, +} + +/// Describes an individual variant of a search engine. +#[derive(Debug, Deserialize, Clone)] +pub(crate) struct JSONEngineVariant { + /// Details of the possible user environments that this variant applies to. + pub environment: JSONVariantEnvironment, +} + /// Represents an individual engine record in the configuration. #[derive(Debug, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub(crate) struct JSONEngineRecord { + /// The identiifer for the search engine. pub identifier: String, + + /// The base information of the search engine, may be extended by the + /// variants. pub base: JSONEngineBase, + + /// Describes variations of this search engine that may occur depending on + /// the user's environment. The last variant that matches the user's + /// environment will be applied to the engine, subvariants may also be applied. + pub variants: Vec, } /// Represents the default engines record. diff --git a/components/search/src/environment_matching.rs b/components/search/src/environment_matching.rs new file mode 100644 index 0000000000..515c70c300 --- /dev/null +++ b/components/search/src/environment_matching.rs @@ -0,0 +1,854 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! This module defines functions for testing if an environment from the +//! configuration matches the user environment. + +use crate::{JSONVariantEnvironment, SearchApplicationName, SearchUserEnvironment}; + +/// Matches the user's environment against the given environment from the +/// configuration. +/// +/// This function expects the locale, region and app version in the environment +/// to be lower case. +pub(crate) fn matches_user_environment( + environment: &JSONVariantEnvironment, + user_environment: &SearchUserEnvironment, +) -> bool { + if environment + .excluded_distributions + .as_ref() + .is_some_and(|distributions| { + distributions.contains(&user_environment.distribution_id.to_string()) + }) + { + return false; + } + + matches_region_and_locale( + &user_environment.region, + &user_environment.locale, + environment, + ) && matches_distribution( + &user_environment.distribution_id, + &environment.distributions, + ) && matches_application(&user_environment.app_name, &environment.applications) +} + +/// Determines whether the region and locale constraints in the supplied +/// environment applies to a user given the region and locale they are using. +fn matches_region_and_locale( + user_region: &str, + user_locale: &str, + environment: &JSONVariantEnvironment, +) -> bool { + if does_array_include(&environment.excluded_regions, user_region) + || does_array_include(&environment.excluded_locales, user_locale) + { + return false; + } + + if environment.all_regions_and_locales.unwrap_or_default() { + return true; + } + + // When none of the regions and locales are set. This implies its available + // everywhere. + if environment.all_regions_and_locales.is_none() + && environment.regions.is_none() + && environment.locales.is_none() + { + return true; + } + + if does_array_include(&environment.regions, user_region) + && does_array_include(&environment.locales, user_locale) + { + return true; + } + + if environment.regions.is_none() && does_array_include(&environment.locales, user_locale) { + return true; + } + + if environment.locales.is_none() && does_array_include(&environment.regions, user_region) { + return true; + } + + false +} + +fn matches_distribution( + user_distribution_id: &str, + environment_distributions: &Option>, +) -> bool { + // TODO: Could use `is_none_or()` once Rust 1.82.0 is in use. + match environment_distributions { + None => true, + Some(distributions) => distributions.contains(&user_distribution_id.to_string()), + } +} + +fn matches_application( + user_application_name: &SearchApplicationName, + environment_applications: &Option>, +) -> bool { + // TODO: Could use `is_none_or()` once Rust 1.82.0 is in use. + match environment_applications { + None => true, + Some(applications) => applications.contains(user_application_name), + } +} + +fn does_array_include(config_array: &Option>, compare_item: &str) -> bool { + config_array + .as_ref() + .is_some_and(|arr| arr.iter().any(|x| x.to_lowercase() == compare_item)) +} + +#[cfg(test)] +mod tests { + use std::vec; + + use super::*; + use crate::*; + + #[test] + fn test_matches_user_environment_all_locales() { + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: Some(true), + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "FR".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when all_regions_and_locales is true" + ); + + assert!( + !matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: Some(false), + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return false when all_regions_and_locales is false" + ); + + assert!( + !matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: Some(true), + excluded_locales: Some(vec!["fi".to_string()]), + excluded_regions: None, + locales: None, + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return false when all_regions_and_locales is true and the locale is excluded" + ); + + assert!( + !matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: Some(true), + excluded_locales: Some(vec!["FI".to_string()]), + excluded_regions: None, + locales: None, + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return false when all_regions_and_locales is true and the excluded locale is a different case" + ); + + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: Some(true), + excluded_locales: Some(vec!["en-US".to_string()]), + excluded_regions: None, + locales: None, + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when all_regions_and_locales is true and the locale is not excluded" + ); + + assert!( + !matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: Some(true), + excluded_locales: None, + excluded_regions: Some(vec!["us".to_string(), "fr".to_string()]), + locales: None, + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return false when all_regions_and_locales is true and the region is excluded" + ); + + assert!( + !matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: Some(true), + excluded_locales: None, + excluded_regions: Some(vec!["US".to_string(), "FR".to_string()]), + locales: None, + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return false when all_regions_and_locales is true and the excluded region is a different case" + ); + + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: Some(true), + excluded_locales: None, + excluded_regions: Some(vec!["us".to_string()]), + locales: None, + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when all_regions_and_locales is true and the region is not excluded" + ); + } + + #[test] + fn test_matches_user_environment_locales() { + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: Some(vec!["en-gb".to_string(), "fi".to_string()]), + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the user locale matches one from the config" + ); + + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: Some(vec!["en-GB".to_string(), "FI".to_string()]), + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the user locale matches one from the config and is a different case" + ); + + assert!( + !matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: Some(vec!["en-gb".to_string(), "en-ca".to_string()]), + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return false when the user locale does not match one from the config" + ); + } + + #[test] + fn test_matches_user_environment_regions() { + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: Some(vec!["gb".to_string(), "fr".to_string()]), + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the user region matches one from the config" + ); + + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: Some(vec!["GB".to_string(), "FR".to_string()]), + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the user region matches one from the config and is a different case" + ); + + assert!( + !matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: Some(vec!["gb".to_string(), "ca".to_string()]), + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return false when the user region does not match one from the config" + ); + } + + #[test] + fn test_matches_user_environment_locales_with_excluded_regions() { + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: Some(vec!["gb".to_string(), "ca".to_string()]), + locales: Some(vec!["en-gb".to_string(), "fi".to_string()]), + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the locale matches and the region is not excluded" + ); + + assert!( + !matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: Some(vec!["gb".to_string(), "fr".to_string()]), + locales: Some(vec!["en-gb".to_string(), "fi".to_string()]), + regions: None, + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return false when the locale matches and the region is excluded" + ); + } + + #[test] + fn test_matches_user_environment_regions_with_excluded_locales() { + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: Some(vec!["en-gb".to_string(), "de".to_string()]), + excluded_regions: None, + locales: None, + regions: Some(vec!["gb".to_string(), "fr".to_string()]), + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the region matches and the locale is not excluded" + ); + + assert!( + !matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: Some(vec!["en-gb".to_string(), "fi".to_string()]), + excluded_regions: None, + locales: None, + regions: Some(vec!["gb".to_string(), "fr".to_string()]), + distributions: None, + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return false when the region matches and the locale is excluded" + ); + } + + #[test] + fn test_matches_user_environment_distributions() { + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: None, + distributions: Some(vec!["distro-1".to_string()]), + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: "distro-1".into(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the distribution matches one in the environment" + ); + + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: None, + distributions: Some(vec!["distro-2".to_string(), "distro-3".to_string()]), + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: "distro-3".into(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the distribution matches one in the environment when there are multiple" + ); + + assert!( + !matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: None, + distributions: Some(vec!["distro-2".to_string(), "distro-3".to_string()]), + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: "distro-4".into(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return false when the distribution does not match any in the environment" + ); + + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: Some(vec!["fr".to_string()]), + distributions: Some(vec!["distro-1".to_string(), "distro-2".to_string()]), + excluded_distributions: None, + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: "distro-2".into(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the distribution and region matches the environment" + ); + } + + #[test] + fn test_matches_user_environment_excluded_distributions() { + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: Some(vec!["fr".to_string()]), + distributions: Some(vec!["distro-1".to_string(), "distro-2".to_string()]), + excluded_distributions: Some(vec![" + distro-3".to_string(), "distro-4".to_string() + ]), + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: "distro-2".into(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the distribution matches the distribution list but not the excluded distributions" + ); + + assert!( + !matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: Some(vec!["fr".to_string()]), + distributions: Some(vec!["distro-1".to_string(), "distro-2".to_string()]), + excluded_distributions: Some(vec![ + "distro-3".to_string(), + "distro-4".to_string() + ]), + applications: None, + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: "distro-3".into(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return false when the distribution matches the the excluded distributions" + ); + } + + #[test] + fn test_matches_user_environment_application_name() { + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: None, + distributions: None, + excluded_distributions: None, + applications: Some(vec![SearchApplicationName::Firefox]), + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the application name matches the one in the environment" + ); + + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: None, + distributions: None, + excluded_distributions: None, + applications: Some(vec![ + SearchApplicationName::FirefoxAndroid, + SearchApplicationName::Firefox + ]), + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: "distro-3".into(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the application name matches one in the environment when there are multiple" + ); + + assert!( + !matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: None, + distributions: None, + excluded_distributions: None, + applications: Some(vec![ + SearchApplicationName::FirefoxAndroid, + SearchApplicationName::Firefox + ]), + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: "distro-4".into(), + experiment: String::new(), + app_name: SearchApplicationName::FirefoxIos, + version: String::new(), + } + ), + "Should return false when the distribution does not match any in the environment" + ); + + assert!( + matches_user_environment( + &crate::JSONVariantEnvironment { + all_regions_and_locales: None, + excluded_locales: None, + excluded_regions: None, + locales: None, + regions: Some(vec!["fr".to_string()]), + distributions: None, + excluded_distributions: None, + applications: Some(vec![ + SearchApplicationName::FirefoxAndroid, + SearchApplicationName::Firefox + ]), + }, + &SearchUserEnvironment { + locale: "fi".into(), + region: "fr".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + } + ), + "Should return true when the distribution and region matches the environment" + ); + } +} diff --git a/components/search/src/error.rs b/components/search/src/error.rs index 25c37e1cf2..58a9755621 100644 --- a/components/search/src/error.rs +++ b/components/search/src/error.rs @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +//! Defines the error types for this module. + /// The error type for all Search component operations. These errors are /// exposed to your application, which should handle them as needed. use error_support::{ErrorHandling, GetErrorHandling}; diff --git a/components/search/src/filter.rs b/components/search/src/filter.rs index 4095654ecf..64b361b451 100644 --- a/components/search/src/filter.rs +++ b/components/search/src/filter.rs @@ -2,6 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +//! This module defines the functions for managing the filtering of the configuration. + +use crate::environment_matching::matches_user_environment; use crate::{ error::Error, JSONEngineBase, JSONEngineRecord, JSONEngineUrl, JSONEngineUrls, JSONSearchConfigurationRecords, RefinedSearchConfig, SearchEngineDefinition, SearchEngineUrl, @@ -56,10 +59,15 @@ pub(crate) fn filter_engine_configuration( let mut default_engine_id: Option = None; let mut default_private_engine_id: Option = None; + let mut user_environment = user_environment.clone(); + user_environment.locale = user_environment.locale.to_lowercase(); + user_environment.region = user_environment.region.to_lowercase(); + user_environment.version = user_environment.version.to_lowercase(); + for record in configuration { match record { JSONSearchConfigurationRecords::Engine(engine) => { - let result = extract_engine_config(&user_environment, engine); + let result = maybe_extract_engine_config(&user_environment, engine); engines.extend(result); } JSONSearchConfigurationRecords::DefaultEngines(default_engines) => { @@ -82,21 +90,26 @@ pub(crate) fn filter_engine_configuration( }) } -fn extract_engine_config( - _user_environment: &SearchUserEnvironment, +fn maybe_extract_engine_config( + user_environment: &SearchUserEnvironment, record: Box, ) -> Option { - // TODO: Variant handling. - Some(SearchEngineDefinition::from_configuration_details( - &record.identifier, - record.base, - )) + let base = record.base; + record + .variants + .iter() + .rev() + .find(|r| matches_user_environment(&r.environment, user_environment)) + .map(|_variant| { + SearchEngineDefinition::from_configuration_details(&record.identifier, base) + }) } #[cfg(test)] mod tests { - use super::*; - use crate::types::*; + use std::vec; + + use crate::*; #[test] fn test_from_configuration_details_fallsback_to_defaults() { diff --git a/components/search/src/lib.rs b/components/search/src/lib.rs index 98cc7cbcf5..81f0caa588 100644 --- a/components/search/src/lib.rs +++ b/components/search/src/lib.rs @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ mod configuration_types; +mod environment_matching; mod error; mod filter; pub use error::SearchApiError; diff --git a/components/search/src/selector.rs b/components/search/src/selector.rs index 892b16eccf..7c276a4324 100644 --- a/components/search/src/selector.rs +++ b/components/search/src/selector.rs @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +//! This module defines the main `SearchEngineSelector`. + use crate::filter::filter_engine_configuration; use crate::{ error::Error, JSONSearchConfiguration, RefinedSearchConfig, SearchApiResult, @@ -89,7 +91,12 @@ mod tests { "method": "GET" } } - } + }, + "variants": [{ + "environment": { + "allRegionsAndLocales": true + } + }], }, { "recordType": "defaultEngines", @@ -127,6 +134,11 @@ mod tests { }, "extraField2": "123" }, + "variants": [{ + "environment": { + "allRegionsAndLocales": true + } + }], "extraField3": ["foo"] }, { @@ -165,7 +177,12 @@ mod tests { "method": "GET" } } - } + }, + "variants": [{ + "environment": { + "allRegionsAndLocales": true + } + }], }, { "recordType": "defaultEngines", @@ -225,7 +242,12 @@ mod tests { "searchTermParamName": "q" } } - } + }, + "variants": [{ + "environment": { + "allRegionsAndLocales": true + } + }], }, { "recordType": "engine", @@ -240,7 +262,12 @@ mod tests { "searchTermParamName": "search" } } - } + }, + "variants": [{ + "environment": { + "allRegionsAndLocales": true + } + }], }, { "recordType": "defaultEngines", @@ -317,4 +344,248 @@ mod tests { } ) } + + #[test] + fn test_filter_engine_configuration_handles_environments() { + let selector = Arc::new(SearchEngineSelector::new()); + + let config_result = Arc::clone(&selector).set_search_config( + json!({ + "data": [ + { + "recordType": "engine", + "identifier": "test1", + "base": { + "name": "Test 1", + "classification": "general", + "urls": { + "search": { + "base": "https://example.com/1", + "method": "GET", + "searchTermParamName": "q" + } + } + }, + "variants": [{ + "environment": { + "allRegionsAndLocales": true + } + }], + }, + { + "recordType": "engine", + "identifier": "test2", + "base": { + "name": "Test 2", + "classification": "general", + "urls": { + "search": { + "base": "https://example.com/2", + "method": "GET", + "searchTermParamName": "search" + } + } + }, + "variants": [{ + "environment": { + "applications": ["firefox-android", "focus-ios"] + } + }], + }, + { + "recordType": "engine", + "identifier": "test3", + "base": { + "name": "Test 3", + "classification": "general", + "urls": { + "search": { + "base": "https://example.com/3", + "method": "GET", + "searchTermParamName": "trek" + } + } + }, + "variants": [{ + "environment": { + "distributions": ["starship"] + } + }], + }, + { + "recordType": "defaultEngines", + "globalDefault": "test1", + } + ] + }) + .to_string(), + ); + assert!( + config_result.is_ok(), + "Should not have errored: `{config_result:?}`" + ); + + let mut result = Arc::clone(&selector).filter_engine_configuration(SearchUserEnvironment { + locale: "fi".into(), + region: "FR".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + }); + + assert!(result.is_ok(), "Should not have errored: `{result:?}`"); + assert_eq!( + result.unwrap(), + RefinedSearchConfig { + engines: vec!( + SearchEngineDefinition { + aliases: Vec::new(), + charset: "UTF-8".to_string(), + classification: SearchEngineClassification::General, + identifier: "test1".to_string(), + name: "Test 1".to_string(), + order_hint: None, + partner_code: String::new(), + telemetry_suffix: None, + urls: SearchEngineUrls { + search: SearchEngineUrl { + base: "https://example.com/1".to_string(), + method: "GET".to_string(), + params: Vec::new(), + search_term_param_name: Some("q".to_string()) + }, + suggestions: None, + trending: None + } + }, + ), + app_default_engine_id: "test1".to_string(), + app_default_private_engine_id: None + }, "Should have selected the single engine, as the environments do not match for the other two" + ); + + result = Arc::clone(&selector).filter_engine_configuration(SearchUserEnvironment { + locale: "fi".into(), + region: "FR".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: String::new(), + experiment: String::new(), + app_name: SearchApplicationName::FocusIos, + version: String::new(), + }); + + assert!(result.is_ok(), "Should not have errored: `{result:?}`"); + assert_eq!( + result.unwrap(), + RefinedSearchConfig { + engines: vec!( + SearchEngineDefinition { + aliases: Vec::new(), + charset: "UTF-8".to_string(), + classification: SearchEngineClassification::General, + identifier: "test1".to_string(), + name: "Test 1".to_string(), + order_hint: None, + partner_code: String::new(), + telemetry_suffix: None, + urls: SearchEngineUrls { + search: SearchEngineUrl { + base: "https://example.com/1".to_string(), + method: "GET".to_string(), + params: Vec::new(), + search_term_param_name: Some("q".to_string()) + }, + suggestions: None, + trending: None + } + }, + SearchEngineDefinition { + aliases: Vec::new(), + charset: "UTF-8".to_string(), + classification: SearchEngineClassification::General, + identifier: "test2".to_string(), + name: "Test 2".to_string(), + order_hint: None, + partner_code: String::new(), + telemetry_suffix: None, + urls: SearchEngineUrls { + search: SearchEngineUrl { + base: "https://example.com/2".to_string(), + method: "GET".to_string(), + params: Vec::new(), + search_term_param_name: Some("search".to_string()) + }, + suggestions: None, + trending: None + } + }, + ), + app_default_engine_id: "test1".to_string(), + app_default_private_engine_id: None + }, "Should have selected the single engine, as the environments do not match for the other two" + ); + + result = Arc::clone(&selector).filter_engine_configuration(SearchUserEnvironment { + locale: "fi".into(), + region: "FR".into(), + update_channel: SearchUpdateChannel::Default, + distribution_id: "starship".to_string(), + experiment: String::new(), + app_name: SearchApplicationName::Firefox, + version: String::new(), + }); + + assert!(result.is_ok(), "Should not have errored: `{result:?}`"); + assert_eq!( + result.unwrap(), + RefinedSearchConfig { + engines: vec!( + SearchEngineDefinition { + aliases: Vec::new(), + charset: "UTF-8".to_string(), + classification: SearchEngineClassification::General, + identifier: "test1".to_string(), + name: "Test 1".to_string(), + order_hint: None, + partner_code: String::new(), + telemetry_suffix: None, + urls: SearchEngineUrls { + search: SearchEngineUrl { + base: "https://example.com/1".to_string(), + method: "GET".to_string(), + params: Vec::new(), + search_term_param_name: Some("q".to_string()) + }, + suggestions: None, + trending: None + } + }, + SearchEngineDefinition { + aliases: Vec::new(), + charset: "UTF-8".to_string(), + classification: SearchEngineClassification::General, + identifier: "test3".to_string(), + name: "Test 3".to_string(), + order_hint: None, + partner_code: String::new(), + telemetry_suffix: None, + urls: SearchEngineUrls { + search: SearchEngineUrl { + base: "https://example.com/3".to_string(), + method: "GET".to_string(), + params: Vec::new(), + search_term_param_name: Some("trek".to_string()) + }, + suggestions: None, + trending: None + } + }, + ), + app_default_engine_id: "test1".to_string(), + app_default_private_engine_id: None + }, "Should have selected the single engine, as the environments do not match for the other two" + ); + } } diff --git a/components/search/src/types.rs b/components/search/src/types.rs index 43c9e04234..d05a1764f1 100644 --- a/components/search/src/types.rs +++ b/components/search/src/types.rs @@ -7,13 +7,14 @@ use serde::Deserialize; /// The list of possible application names that are currently supported. -#[derive(Debug, uniffi::Enum)] +#[derive(Clone, Debug, Deserialize, PartialEq, uniffi::Enum)] +#[serde(rename_all = "kebab-case")] pub enum SearchApplicationName { Firefox = 1, FirefoxAndroid = 2, - FirefoxIOS = 3, + FirefoxIos = 3, FocusAndroid = 4, - FocusIOS = 5, + FocusIos = 5, } impl SearchApplicationName { @@ -22,15 +23,15 @@ impl SearchApplicationName { SearchApplicationName::Firefox => "firefox", SearchApplicationName::FirefoxAndroid => "firefox-android", SearchApplicationName::FocusAndroid => "focus-android", - SearchApplicationName::FirefoxIOS => "firefox-ios", - SearchApplicationName::FocusIOS => "focus-ios", + SearchApplicationName::FirefoxIos => "firefox-ios", + SearchApplicationName::FocusIos => "focus-ios", } } } /// The list of possible update channels for a user's build. /// Use `default` for a self-build or an unknown channel. -#[derive(Debug, uniffi::Enum)] +#[derive(Clone, Debug, uniffi::Enum)] pub enum SearchUpdateChannel { Default = 1, Nightly = 2, @@ -54,7 +55,7 @@ impl SearchUpdateChannel { } /// The user's environment that is used for filtering the search configuration. -#[derive(Debug, uniffi::Record)] +#[derive(Clone, Debug, uniffi::Record)] pub struct SearchUserEnvironment { /// The current locale of the application that the user is using. pub locale: String, @@ -84,6 +85,7 @@ pub struct SearchUserEnvironment { /// Parameter definitions for search engine URLs. The name property is always /// specified, along with one of value, experiment_config or search_access_point. #[derive(Debug, uniffi::Record, PartialEq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] pub struct SearchUrlParam { /// The name of the parameter in the url. pub name: String,