search/
filter.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5//! This module defines the functions for managing the filtering of the configuration.
6
7use crate::configuration_overrides_types::JSONOverridesRecord;
8use crate::environment_matching::matches_user_environment;
9use crate::{
10    error::Error, JSONDefaultEnginesRecord, JSONEngineBase, JSONEngineMethod, JSONEngineRecord,
11    JSONEngineUrl, JSONEngineUrls, JSONEngineVariant, JSONSearchConfigurationRecords,
12    RefinedSearchConfig, SearchEngineDefinition, SearchEngineUrl, SearchEngineUrls,
13    SearchUserEnvironment,
14};
15use crate::{sort_helpers, JSONAvailableLocalesRecord, JSONEngineOrdersRecord};
16use remote_settings::RemoteSettingsRecord;
17use std::collections::HashSet;
18
19impl Default for SearchEngineUrl {
20    fn default() -> Self {
21        Self {
22            base: Default::default(),
23            method: JSONEngineMethod::default().as_str().to_string(),
24            params: Default::default(),
25            search_term_param_name: Default::default(),
26            display_name: Default::default(),
27            is_new_until: Default::default(),
28            exclude_partner_code_from_telemetry: Default::default(),
29        }
30    }
31}
32
33impl SearchEngineUrl {
34    fn merge(&mut self, user_environment: &SearchUserEnvironment, preferred: &JSONEngineUrl) {
35        if let Some(base) = &preferred.base {
36            self.base = base.clone();
37        }
38        if let Some(method) = &preferred.method {
39            self.method = method.as_str().to_string();
40        }
41        if let Some(params) = &preferred.params {
42            self.params = params.clone();
43        }
44        if let Some(search_term_param_name) = &preferred.search_term_param_name {
45            self.search_term_param_name = Some(search_term_param_name.clone());
46        }
47        if let Some(display_name_map) = &preferred.display_name_map {
48            self.display_name = display_name_map
49                .get(&user_environment.locale)
50                .or_else(|| display_name_map.get("default"))
51                .cloned();
52        }
53        if let Some(is_new_until) = &preferred.is_new_until {
54            self.is_new_until = Some(is_new_until.clone());
55        }
56        self.exclude_partner_code_from_telemetry = preferred.exclude_partner_code_from_telemetry;
57    }
58}
59
60impl SearchEngineUrls {
61    fn merge(&mut self, user_environment: &SearchUserEnvironment, preferred: &JSONEngineUrls) {
62        if let Some(search_url) = &preferred.search {
63            self.search.merge(user_environment, search_url);
64        }
65        if let Some(suggestions) = &preferred.suggestions {
66            self.suggestions
67                .get_or_insert_with(Default::default)
68                .merge(user_environment, suggestions);
69        }
70        if let Some(trending) = &preferred.trending {
71            self.trending
72                .get_or_insert_with(Default::default)
73                .merge(user_environment, trending);
74        }
75        if let Some(search_form) = &preferred.search_form {
76            self.search_form
77                .get_or_insert_with(Default::default)
78                .merge(user_environment, search_form);
79        }
80        if let Some(visual_search) = &preferred.visual_search {
81            self.visual_search
82                .get_or_insert_with(Default::default)
83                .merge(user_environment, visual_search);
84        }
85    }
86}
87
88impl SearchEngineDefinition {
89    fn merge_variant(
90        &mut self,
91        user_environment: &SearchUserEnvironment,
92        variant: &JSONEngineVariant,
93    ) {
94        if !self.optional {
95            self.optional = variant.optional;
96        }
97        if let Some(partner_code) = &variant.partner_code {
98            self.partner_code = partner_code.clone();
99        }
100        if let Some(telemetry_suffix) = &variant.telemetry_suffix {
101            self.telemetry_suffix = telemetry_suffix.clone();
102        }
103        if let Some(urls) = &variant.urls {
104            self.urls.merge(user_environment, urls);
105        }
106        if let Some(is_new_until) = &variant.is_new_until {
107            self.is_new_until = Some(is_new_until.clone());
108        }
109    }
110
111    fn merge_override(
112        &mut self,
113        user_environment: &SearchUserEnvironment,
114        override_record: &JSONOverridesRecord,
115    ) {
116        self.partner_code = override_record.partner_code.clone();
117        self.urls.merge(user_environment, &override_record.urls);
118        self.click_url = Some(override_record.click_url.clone());
119
120        if let Some(telemetry_suffix) = &override_record.telemetry_suffix {
121            self.telemetry_suffix = telemetry_suffix.clone();
122        }
123    }
124
125    pub(crate) fn from_configuration_details(
126        user_environment: &SearchUserEnvironment,
127        identifier: &str,
128        base: JSONEngineBase,
129        variant: &JSONEngineVariant,
130        sub_variant: &Option<JSONEngineVariant>,
131    ) -> SearchEngineDefinition {
132        let mut engine_definition = SearchEngineDefinition {
133            aliases: base.aliases.unwrap_or_default(),
134            charset: base.charset.unwrap_or_else(|| "UTF-8".to_string()),
135            classification: base.classification,
136            identifier: identifier.to_string(),
137            name: base.name,
138            optional: variant.optional,
139            order_hint: None,
140            partner_code: base.partner_code.unwrap_or_default(),
141            telemetry_suffix: String::new(),
142            urls: SearchEngineUrls::default(),
143            click_url: None,
144            is_new_until: None,
145        };
146
147        engine_definition.urls.merge(user_environment, &base.urls);
148        engine_definition.merge_variant(user_environment, variant);
149        if let Some(sub_variant) = sub_variant {
150            engine_definition.merge_variant(user_environment, sub_variant);
151        }
152
153        engine_definition
154    }
155}
156
157pub(crate) struct FilterRecordsResult {
158    engines: Vec<SearchEngineDefinition>,
159    default_engines_record: Option<JSONDefaultEnginesRecord>,
160    engine_orders_record: Option<JSONEngineOrdersRecord>,
161}
162
163pub(crate) trait Filter {
164    fn filter_records(
165        &self,
166        user_environment: &mut SearchUserEnvironment,
167        overrides: Option<Vec<JSONOverridesRecord>>,
168    ) -> Result<FilterRecordsResult, Error>;
169}
170
171fn apply_overrides(
172    user_environment: &SearchUserEnvironment,
173    engines: &mut [SearchEngineDefinition],
174    overrides: &[JSONOverridesRecord],
175) {
176    for override_record in overrides {
177        for engine in engines.iter_mut() {
178            if engine.identifier == override_record.identifier {
179                engine.merge_override(user_environment, override_record);
180            }
181        }
182    }
183}
184
185fn negotiate_languages(user_environment: &mut SearchUserEnvironment, available_locales: &[String]) {
186    let user_locale = user_environment.locale.to_lowercase();
187
188    let available_locales_set: HashSet<String> = available_locales
189        .iter()
190        .map(|locale| locale.to_lowercase())
191        .collect();
192
193    if available_locales_set.contains(&user_locale) {
194        return;
195    }
196    if user_locale.starts_with("en-") {
197        user_environment.locale = "en-us".to_string();
198        return;
199    }
200    if let Some(index) = user_locale.find('-') {
201        let base_locale = &user_locale[..index];
202        if available_locales_set.contains(base_locale) {
203            user_environment.locale = base_locale.to_string();
204        }
205    }
206}
207
208impl Filter for Vec<RemoteSettingsRecord> {
209    fn filter_records(
210        &self,
211        user_environment: &mut SearchUserEnvironment,
212        overrides: Option<Vec<JSONOverridesRecord>>,
213    ) -> Result<FilterRecordsResult, Error> {
214        let mut available_locales = Vec::new();
215        for record in self {
216            if let Some(val) = record.fields.get("recordType") {
217                if *val == "availableLocales" {
218                    let stringified = serde_json::to_string(&record.fields)?;
219                    let locales_record: Option<JSONAvailableLocalesRecord> =
220                        serde_json::from_str(&stringified)?;
221                    available_locales = locales_record.unwrap().locales;
222                }
223            }
224        }
225        negotiate_languages(user_environment, &available_locales);
226
227        let mut engines = Vec::new();
228        let mut default_engines_record = None;
229        let mut engine_orders_record = None;
230
231        for record in self {
232            // TODO: Bug 1947241 - Find a way to avoid having to serialise the records
233            // back to strings and then deserialise them into the records that we want.
234            let stringified = serde_json::to_string(&record.fields)?;
235            match record.fields.get("recordType") {
236                Some(val) if *val == "engine" => {
237                    let engine_config: Option<JSONEngineRecord> =
238                        serde_json::from_str(&stringified)?;
239                    if let Some(engine_config) = engine_config {
240                        let result =
241                            maybe_extract_engine_config(user_environment, Box::new(engine_config));
242                        engines.extend(result);
243                    }
244                }
245                Some(val) if *val == "defaultEngines" => {
246                    default_engines_record = serde_json::from_str(&stringified)?;
247                }
248                Some(val) if *val == "engineOrders" => {
249                    engine_orders_record = serde_json::from_str(&stringified)?;
250                }
251                Some(val) if *val == "availableLocales" => {
252                    // Handled above
253                }
254                // These cases are acceptable - we expect the potential for new
255                // record types/options so that we can be flexible.
256                Some(_val) => {}
257                None => {}
258            }
259        }
260
261        if let Some(overrides_data) = &overrides {
262            apply_overrides(user_environment, &mut engines, overrides_data);
263        }
264
265        Ok(FilterRecordsResult {
266            engines,
267            default_engines_record,
268            engine_orders_record,
269        })
270    }
271}
272
273impl Filter for Vec<JSONSearchConfigurationRecords> {
274    fn filter_records(
275        &self,
276        user_environment: &mut SearchUserEnvironment,
277        overrides: Option<Vec<JSONOverridesRecord>>,
278    ) -> Result<FilterRecordsResult, Error> {
279        let mut available_locales = Vec::new();
280        for record in self {
281            if let JSONSearchConfigurationRecords::AvailableLocales(locales_record) = record {
282                available_locales = locales_record.locales.clone();
283            }
284        }
285        negotiate_languages(user_environment, &available_locales);
286
287        let mut engines = Vec::new();
288        let mut default_engines_record = None;
289        let mut engine_orders_record = None;
290
291        for record in self {
292            match record {
293                JSONSearchConfigurationRecords::Engine(engine) => {
294                    let result = maybe_extract_engine_config(user_environment, engine.clone());
295                    engines.extend(result);
296                }
297                JSONSearchConfigurationRecords::DefaultEngines(default_engines) => {
298                    default_engines_record = Some(default_engines);
299                }
300                JSONSearchConfigurationRecords::EngineOrders(engine_orders) => {
301                    engine_orders_record = Some(engine_orders)
302                }
303                JSONSearchConfigurationRecords::AvailableLocales(_) => {
304                    // Handled above
305                }
306                JSONSearchConfigurationRecords::Unknown => {
307                    // Prevents panics if a new record type is added in future.
308                }
309            }
310        }
311
312        if let Some(overrides_data) = &overrides {
313            apply_overrides(user_environment, &mut engines, overrides_data);
314        }
315
316        Ok(FilterRecordsResult {
317            engines,
318            default_engines_record: default_engines_record.cloned(),
319            engine_orders_record: engine_orders_record.cloned(),
320        })
321    }
322}
323
324pub(crate) fn filter_engine_configuration_impl(
325    user_environment: SearchUserEnvironment,
326    configuration: &impl Filter,
327    overrides: Option<Vec<JSONOverridesRecord>>,
328) -> Result<RefinedSearchConfig, Error> {
329    let mut user_environment = user_environment.clone();
330    user_environment.locale = user_environment.locale.to_lowercase();
331    user_environment.region = user_environment.region.to_lowercase();
332    user_environment.version = user_environment.version.to_lowercase();
333
334    let filtered_result = configuration.filter_records(&mut user_environment, overrides);
335
336    filtered_result.map(|result| {
337        let (default_engine_id, default_private_engine_id) = determine_default_engines(
338            &result.engines,
339            result.default_engines_record,
340            &user_environment,
341        );
342
343        let mut engines = result.engines.clone();
344
345        if let Some(orders_record) = result.engine_orders_record {
346            for order_data in &orders_record.orders {
347                if matches_user_environment(&order_data.environment, &user_environment) {
348                    sort_helpers::set_engine_order(&mut engines, &order_data.order);
349                }
350            }
351        }
352
353        engines.sort_by(|a, b| {
354            sort_helpers::sort(
355                default_engine_id.as_ref(),
356                default_private_engine_id.as_ref(),
357                a,
358                b,
359            )
360        });
361
362        RefinedSearchConfig {
363            engines,
364            app_default_engine_id: default_engine_id,
365            app_private_default_engine_id: default_private_engine_id,
366        }
367    })
368}
369
370fn maybe_extract_engine_config(
371    user_environment: &SearchUserEnvironment,
372    record: Box<JSONEngineRecord>,
373) -> Option<SearchEngineDefinition> {
374    let JSONEngineRecord {
375        identifier,
376        variants,
377        base,
378    } = *record;
379    let matching_variant = variants
380        .into_iter()
381        .rev()
382        .find(|r| matches_user_environment(&r.environment, user_environment));
383
384    let mut matching_sub_variant = None;
385    if let Some(variant) = &matching_variant {
386        matching_sub_variant = variant
387            .sub_variants
388            .iter()
389            .rev()
390            .find(|r| matches_user_environment(&r.environment, user_environment))
391            .cloned();
392    }
393
394    matching_variant.map(|variant| {
395        SearchEngineDefinition::from_configuration_details(
396            user_environment,
397            &identifier,
398            base,
399            &variant,
400            &matching_sub_variant,
401        )
402    })
403}
404
405fn determine_default_engines(
406    engines: &[SearchEngineDefinition],
407    default_engines_record: Option<JSONDefaultEnginesRecord>,
408    user_environment: &SearchUserEnvironment,
409) -> (Option<String>, Option<String>) {
410    match default_engines_record {
411        None => (None, None),
412        Some(record) => {
413            let mut default_engine_id = None;
414            let mut default_engine_private_id = None;
415
416            let specific_default = record
417                .specific_defaults
418                .into_iter()
419                .rev()
420                .find(|r| matches_user_environment(&r.environment, user_environment));
421
422            if let Some(specific_default) = specific_default {
423                // Check the engine is present in the list of engines before
424                // we return it as default.
425                if let Some(engine_id) =
426                    find_engine_id_with_match(engines, specific_default.default)
427                {
428                    default_engine_id.replace(engine_id);
429                }
430                if let Some(private_engine_id) =
431                    find_engine_id_with_match(engines, specific_default.default_private)
432                {
433                    default_engine_private_id.replace(private_engine_id);
434                }
435            }
436
437            (
438                // If we haven't found a default engine in a specific default,
439                // then fall back to the global default engine - but only if that
440                // exists in the engine list.
441                //
442                // For the normal mode engine (`default_engine_id`), this would
443                // effectively be considered an error. However, we can't do anything
444                // sensible here, so we will return `None` to the application, and
445                // that can handle it.
446                default_engine_id.or_else(|| find_engine_id(engines, record.global_default)),
447                default_engine_private_id
448                    .or_else(|| find_engine_id(engines, record.global_default_private)),
449            )
450        }
451    }
452}
453
454fn find_engine_id(engines: &[SearchEngineDefinition], engine_id: String) -> Option<String> {
455    if engine_id.is_empty() {
456        return None;
457    }
458    match engines.iter().any(|e| e.identifier == engine_id) {
459        true => Some(engine_id.clone()),
460        false => None,
461    }
462}
463
464fn find_engine_id_with_match(
465    engines: &[SearchEngineDefinition],
466    engine_id_match: String,
467) -> Option<String> {
468    if engine_id_match.is_empty() {
469        return None;
470    }
471    if let Some(match_no_star) = engine_id_match.strip_suffix('*') {
472        return engines
473            .iter()
474            .find(|e| e.identifier.starts_with(match_no_star))
475            .map(|e| e.identifier.clone());
476    }
477
478    engines
479        .iter()
480        .find(|e| e.identifier == engine_id_match)
481        .map(|e| e.identifier.clone())
482}
483
484#[cfg(test)]
485mod tests {
486    use std::{collections::HashMap, vec};
487
488    use super::*;
489    use crate::*;
490    use once_cell::sync::Lazy;
491
492    #[test]
493    fn test_default_search_engine_url() {
494        assert_eq!(
495            SearchEngineUrl::default(),
496            SearchEngineUrl {
497                base: "".to_string(),
498                method: "GET".to_string(),
499                params: Vec::new(),
500                search_term_param_name: None,
501                display_name: None,
502                is_new_until: None,
503                exclude_partner_code_from_telemetry: false,
504            },
505        );
506    }
507
508    #[test]
509    fn test_default_search_engine_urls() {
510        assert_eq!(
511            SearchEngineUrls::default(),
512            SearchEngineUrls {
513                search: SearchEngineUrl::default(),
514                suggestions: None,
515                trending: None,
516                search_form: None,
517                visual_search: None,
518            },
519        );
520    }
521
522    #[test]
523    fn test_merge_override() {
524        let mut test_engine = SearchEngineDefinition {
525            identifier: "test".to_string(),
526            partner_code: "partner-code".to_string(),
527            telemetry_suffix: "original-telemetry-suffix".to_string(),
528            ..Default::default()
529        };
530
531        let override_record = JSONOverridesRecord {
532            identifier: "test".to_string(),
533            partner_code: "override-partner-code".to_string(),
534            click_url: "https://example.com/click-url".to_string(),
535            telemetry_suffix: None,
536            urls: JSONEngineUrls {
537                search: Some(JSONEngineUrl {
538                    base: Some("https://example.com/override-search".to_string()),
539                    ..Default::default()
540                }),
541                ..Default::default()
542            },
543        };
544
545        test_engine.merge_override(
546            &SearchUserEnvironment {
547                locale: "fi".into(),
548                ..Default::default()
549            },
550            &override_record,
551        );
552
553        assert_eq!(
554            test_engine.partner_code, "override-partner-code",
555            "Should override the partner code"
556        );
557        assert_eq!(
558            test_engine.click_url,
559            Some("https://example.com/click-url".to_string()),
560            "Should override the click url"
561        );
562        assert_eq!(
563            test_engine.urls.search.base, "https://example.com/override-search",
564            "Should override search url"
565        );
566        assert_eq!(
567            test_engine.telemetry_suffix, "original-telemetry-suffix",
568            "Should not override telemetry suffix when telemetry suffix is supplied as None"
569        );
570    }
571
572    #[test]
573    fn test_merge_override_locale_match() {
574        let mut test_engine = SearchEngineDefinition {
575            identifier: "test".to_string(),
576            partner_code: "partner-code".to_string(),
577            telemetry_suffix: "original-telemetry-suffix".to_string(),
578            ..Default::default()
579        };
580
581        let override_record = JSONOverridesRecord {
582            identifier: "test".to_string(),
583            partner_code: "override-partner-code".to_string(),
584            click_url: "https://example.com/click-url".to_string(),
585            telemetry_suffix: None,
586            urls: JSONEngineUrls {
587                search: Some(JSONEngineUrl {
588                    base: Some("https://example.com/override-search".to_string()),
589                    display_name_map: Some(HashMap::from([
590                        // Default display name
591                        ("default".to_string(), "My Display Name".to_string()),
592                        // en-GB locale with unique display name
593                        ("en-GB".to_string(), "en-GB Display Name".to_string()),
594                    ])),
595                    ..Default::default()
596                }),
597                ..Default::default()
598            },
599        };
600
601        test_engine.merge_override(
602            &SearchUserEnvironment {
603                // en-GB locale
604                locale: "en-GB".into(),
605                ..Default::default()
606            },
607            &override_record,
608        );
609
610        assert_eq!(
611            test_engine.urls.search.display_name,
612            Some("en-GB Display Name".to_string()),
613            "Should override display name with en-GB version"
614        );
615    }
616
617    #[test]
618    fn test_from_configuration_details_fallsback_to_defaults() {
619        // This test doesn't use `..Default::default()` as we want to
620        // be explicit about `JSONEngineBase` and handling `None`
621        // options/default values.
622        let result = SearchEngineDefinition::from_configuration_details(
623            &SearchUserEnvironment {
624                locale: "fi".into(),
625                ..Default::default()
626            },
627            "test",
628            JSONEngineBase {
629                aliases: None,
630                charset: None,
631                classification: SearchEngineClassification::General,
632                name: "Test".to_string(),
633                partner_code: None,
634                urls: JSONEngineUrls {
635                    search: Some(JSONEngineUrl {
636                        base: Some("https://example.com".to_string()),
637                        ..Default::default()
638                    }),
639                    suggestions: None,
640                    trending: None,
641                    search_form: None,
642                    visual_search: None,
643                },
644            },
645            &JSONEngineVariant {
646                environment: JSONVariantEnvironment {
647                    all_regions_and_locales: true,
648                    ..Default::default()
649                },
650                is_new_until: None,
651                optional: false,
652                partner_code: None,
653                telemetry_suffix: None,
654                urls: None,
655                sub_variants: vec![],
656            },
657            &None,
658        );
659
660        assert_eq!(
661            result,
662            SearchEngineDefinition {
663                aliases: Vec::new(),
664                charset: "UTF-8".to_string(),
665                classification: SearchEngineClassification::General,
666                identifier: "test".to_string(),
667                is_new_until: None,
668                partner_code: String::new(),
669                name: "Test".to_string(),
670                optional: false,
671                order_hint: None,
672                telemetry_suffix: String::new(),
673                urls: SearchEngineUrls {
674                    search: SearchEngineUrl {
675                        base: "https://example.com".to_string(),
676                        ..Default::default()
677                    },
678                    suggestions: None,
679                    trending: None,
680                    search_form: None,
681                    visual_search: None,
682                },
683                click_url: None
684            }
685        )
686    }
687
688    static ENGINE_BASE: Lazy<JSONEngineBase> = Lazy::new(|| JSONEngineBase {
689        aliases: Some(vec!["foo".to_string(), "bar".to_string()]),
690        charset: Some("ISO-8859-15".to_string()),
691        classification: SearchEngineClassification::Unknown,
692        name: "Test".to_string(),
693        partner_code: Some("firefox".to_string()),
694        urls: JSONEngineUrls {
695            search: Some(JSONEngineUrl {
696                base: Some("https://example.com".to_string()),
697                method: Some(crate::JSONEngineMethod::Post),
698                params: Some(vec![
699                    SearchUrlParam {
700                        name: "param".to_string(),
701                        value: Some("test param".to_string()),
702                        enterprise_value: None,
703                        experiment_config: None,
704                    },
705                    SearchUrlParam {
706                        name: "enterprise-name".to_string(),
707                        value: None,
708                        enterprise_value: Some("enterprise-value".to_string()),
709                        experiment_config: None,
710                    },
711                ]),
712                search_term_param_name: Some("baz".to_string()),
713                ..Default::default()
714            }),
715            suggestions: Some(JSONEngineUrl {
716                base: Some("https://example.com/suggestions".to_string()),
717                method: Some(crate::JSONEngineMethod::Get),
718                params: Some(vec![SearchUrlParam {
719                    name: "suggest-name".to_string(),
720                    value: None,
721                    enterprise_value: None,
722                    experiment_config: Some("suggest-experiment-value".to_string()),
723                }]),
724                search_term_param_name: Some("suggest".to_string()),
725                ..Default::default()
726            }),
727            trending: Some(JSONEngineUrl {
728                base: Some("https://example.com/trending".to_string()),
729                method: Some(crate::JSONEngineMethod::Get),
730                params: Some(vec![SearchUrlParam {
731                    name: "trend-name".to_string(),
732                    value: Some("trend-value".to_string()),
733                    enterprise_value: None,
734                    experiment_config: None,
735                }]),
736                ..Default::default()
737            }),
738            search_form: Some(JSONEngineUrl {
739                base: Some("https://example.com/search_form".to_string()),
740                method: Some(crate::JSONEngineMethod::Get),
741                params: Some(vec![SearchUrlParam {
742                    name: "search-form-name".to_string(),
743                    value: Some("search-form-value".to_string()),
744                    enterprise_value: None,
745                    experiment_config: None,
746                }]),
747                ..Default::default()
748            }),
749            visual_search: Some(JSONEngineUrl {
750                base: Some("https://example.com/visual_search".to_string()),
751                method: Some(crate::JSONEngineMethod::Get),
752                params: Some(vec![SearchUrlParam {
753                    name: "visual-search-name".to_string(),
754                    value: Some("visual-search-value".to_string()),
755                    enterprise_value: None,
756                    experiment_config: None,
757                }]),
758                search_term_param_name: Some("url".to_string()),
759                display_name_map: Some(HashMap::from([
760                    // Default display name
761                    ("default".to_string(), "Visual Search".to_string()),
762                    // en-GB locale with unique display name
763                    ("en-GB".to_string(), "Visual Search en-GB".to_string()),
764                ])),
765                is_new_until: Some("2095-01-01".to_string()),
766                exclude_partner_code_from_telemetry: true,
767            }),
768        },
769    });
770
771    #[test]
772    fn test_from_configuration_details_uses_values() {
773        let result = SearchEngineDefinition::from_configuration_details(
774            &SearchUserEnvironment {
775                locale: "fi".into(),
776                ..Default::default()
777            },
778            "test",
779            Lazy::force(&ENGINE_BASE).clone(),
780            &JSONEngineVariant {
781                environment: JSONVariantEnvironment {
782                    all_regions_and_locales: true,
783                    ..Default::default()
784                },
785                is_new_until: None,
786                optional: false,
787                partner_code: None,
788                telemetry_suffix: None,
789                urls: None,
790                sub_variants: vec![],
791            },
792            &None,
793        );
794
795        assert_eq!(
796            result,
797            SearchEngineDefinition {
798                aliases: vec!["foo".to_string(), "bar".to_string()],
799                charset: "ISO-8859-15".to_string(),
800                classification: SearchEngineClassification::Unknown,
801                identifier: "test".to_string(),
802                is_new_until: None,
803                partner_code: "firefox".to_string(),
804                name: "Test".to_string(),
805                optional: false,
806                order_hint: None,
807                telemetry_suffix: String::new(),
808                urls: SearchEngineUrls {
809                    search: SearchEngineUrl {
810                        base: "https://example.com".to_string(),
811                        method: "POST".to_string(),
812                        params: vec![
813                            SearchUrlParam {
814                                name: "param".to_string(),
815                                value: Some("test param".to_string()),
816                                enterprise_value: None,
817                                experiment_config: None,
818                            },
819                            SearchUrlParam {
820                                name: "enterprise-name".to_string(),
821                                value: None,
822                                enterprise_value: Some("enterprise-value".to_string()),
823                                experiment_config: None,
824                            },
825                        ],
826                        search_term_param_name: Some("baz".to_string()),
827                        ..Default::default()
828                    },
829                    suggestions: Some(SearchEngineUrl {
830                        base: "https://example.com/suggestions".to_string(),
831                        method: "GET".to_string(),
832                        params: vec![SearchUrlParam {
833                            name: "suggest-name".to_string(),
834                            value: None,
835                            enterprise_value: None,
836                            experiment_config: Some("suggest-experiment-value".to_string()),
837                        }],
838                        search_term_param_name: Some("suggest".to_string()),
839                        ..Default::default()
840                    }),
841                    trending: Some(SearchEngineUrl {
842                        base: "https://example.com/trending".to_string(),
843                        method: "GET".to_string(),
844                        params: vec![SearchUrlParam {
845                            name: "trend-name".to_string(),
846                            value: Some("trend-value".to_string()),
847                            enterprise_value: None,
848                            experiment_config: None,
849                        }],
850                        ..Default::default()
851                    }),
852                    search_form: Some(SearchEngineUrl {
853                        base: "https://example.com/search_form".to_string(),
854                        method: "GET".to_string(),
855                        params: vec![SearchUrlParam {
856                            name: "search-form-name".to_string(),
857                            value: Some("search-form-value".to_string()),
858                            enterprise_value: None,
859                            experiment_config: None,
860                        }],
861                        ..Default::default()
862                    }),
863                    visual_search: Some(SearchEngineUrl {
864                        base: "https://example.com/visual_search".to_string(),
865                        method: "GET".to_string(),
866                        params: vec![SearchUrlParam {
867                            name: "visual-search-name".to_string(),
868                            value: Some("visual-search-value".to_string()),
869                            enterprise_value: None,
870                            experiment_config: None,
871                        }],
872                        search_term_param_name: Some("url".to_string()),
873                        display_name: Some("Visual Search".to_string()),
874                        is_new_until: Some("2095-01-01".to_string()),
875                        exclude_partner_code_from_telemetry: true,
876                    }),
877                },
878                click_url: None
879            }
880        )
881    }
882
883    #[test]
884    fn test_from_configuration_details_uses_values_locale_match() {
885        let result = SearchEngineDefinition::from_configuration_details(
886            &SearchUserEnvironment {
887                // en-GB locale
888                locale: "en-GB".into(),
889                ..Default::default()
890            },
891            "test",
892            Lazy::force(&ENGINE_BASE).clone(),
893            &JSONEngineVariant {
894                environment: JSONVariantEnvironment {
895                    all_regions_and_locales: true,
896                    ..Default::default()
897                },
898                is_new_until: None,
899                optional: false,
900                partner_code: None,
901                telemetry_suffix: None,
902                urls: None,
903                sub_variants: vec![],
904            },
905            &None,
906        );
907
908        assert_eq!(
909            result,
910            SearchEngineDefinition {
911                aliases: vec!["foo".to_string(), "bar".to_string()],
912                charset: "ISO-8859-15".to_string(),
913                classification: SearchEngineClassification::Unknown,
914                identifier: "test".to_string(),
915                is_new_until: None,
916                partner_code: "firefox".to_string(),
917                name: "Test".to_string(),
918                optional: false,
919                order_hint: None,
920                telemetry_suffix: String::new(),
921                urls: SearchEngineUrls {
922                    search: SearchEngineUrl {
923                        base: "https://example.com".to_string(),
924                        method: "POST".to_string(),
925                        params: vec![
926                            SearchUrlParam {
927                                name: "param".to_string(),
928                                value: Some("test param".to_string()),
929                                enterprise_value: None,
930                                experiment_config: None,
931                            },
932                            SearchUrlParam {
933                                name: "enterprise-name".to_string(),
934                                value: None,
935                                enterprise_value: Some("enterprise-value".to_string()),
936                                experiment_config: None,
937                            },
938                        ],
939                        search_term_param_name: Some("baz".to_string()),
940                        ..Default::default()
941                    },
942                    suggestions: Some(SearchEngineUrl {
943                        base: "https://example.com/suggestions".to_string(),
944                        method: "GET".to_string(),
945                        params: vec![SearchUrlParam {
946                            name: "suggest-name".to_string(),
947                            value: None,
948                            enterprise_value: None,
949                            experiment_config: Some("suggest-experiment-value".to_string()),
950                        }],
951                        search_term_param_name: Some("suggest".to_string()),
952                        ..Default::default()
953                    }),
954                    trending: Some(SearchEngineUrl {
955                        base: "https://example.com/trending".to_string(),
956                        method: "GET".to_string(),
957                        params: vec![SearchUrlParam {
958                            name: "trend-name".to_string(),
959                            value: Some("trend-value".to_string()),
960                            enterprise_value: None,
961                            experiment_config: None,
962                        }],
963                        ..Default::default()
964                    }),
965                    search_form: Some(SearchEngineUrl {
966                        base: "https://example.com/search_form".to_string(),
967                        method: "GET".to_string(),
968                        params: vec![SearchUrlParam {
969                            name: "search-form-name".to_string(),
970                            value: Some("search-form-value".to_string()),
971                            enterprise_value: None,
972                            experiment_config: None,
973                        }],
974                        ..Default::default()
975                    }),
976                    visual_search: Some(SearchEngineUrl {
977                        base: "https://example.com/visual_search".to_string(),
978                        method: "GET".to_string(),
979                        params: vec![SearchUrlParam {
980                            name: "visual-search-name".to_string(),
981                            value: Some("visual-search-value".to_string()),
982                            enterprise_value: None,
983                            experiment_config: None,
984                        }],
985                        search_term_param_name: Some("url".to_string()),
986                        // Should be the en-GB display name since the "en-GB"
987                        // locale is present in `display_name_map`.
988                        display_name: Some("Visual Search en-GB".to_string()),
989                        is_new_until: Some("2095-01-01".to_string()),
990                        exclude_partner_code_from_telemetry: true,
991                    }),
992                },
993                click_url: None
994            }
995        )
996    }
997
998    static ENGINE_VARIANT: Lazy<JSONEngineVariant> = Lazy::new(|| JSONEngineVariant {
999        environment: JSONVariantEnvironment {
1000            all_regions_and_locales: true,
1001            ..Default::default()
1002        },
1003        is_new_until: Some("2063-04-05".to_string()),
1004        optional: true,
1005        partner_code: Some("trek".to_string()),
1006        telemetry_suffix: Some("star".to_string()),
1007        urls: Some(JSONEngineUrls {
1008            search: Some(JSONEngineUrl {
1009                base: Some("https://example.com/variant".to_string()),
1010                method: Some(JSONEngineMethod::Get),
1011                params: Some(vec![SearchUrlParam {
1012                    name: "variant".to_string(),
1013                    value: Some("test variant".to_string()),
1014                    enterprise_value: None,
1015                    experiment_config: None,
1016                }]),
1017                search_term_param_name: Some("ship".to_string()),
1018                ..Default::default()
1019            }),
1020            suggestions: Some(JSONEngineUrl {
1021                base: Some("https://example.com/suggestions-variant".to_string()),
1022                method: Some(JSONEngineMethod::Get),
1023                params: Some(vec![SearchUrlParam {
1024                    name: "suggest-variant".to_string(),
1025                    value: Some("sugg test variant".to_string()),
1026                    enterprise_value: None,
1027                    experiment_config: None,
1028                }]),
1029                search_term_param_name: Some("variant".to_string()),
1030                ..Default::default()
1031            }),
1032            trending: Some(JSONEngineUrl {
1033                base: Some("https://example.com/trending-variant".to_string()),
1034                method: Some(JSONEngineMethod::Get),
1035                params: Some(vec![SearchUrlParam {
1036                    name: "trend-variant".to_string(),
1037                    value: Some("trend test variant".to_string()),
1038                    enterprise_value: None,
1039                    experiment_config: None,
1040                }]),
1041                search_term_param_name: Some("trend".to_string()),
1042                exclude_partner_code_from_telemetry: true,
1043                ..Default::default()
1044            }),
1045            search_form: Some(JSONEngineUrl {
1046                base: Some("https://example.com/search_form".to_string()),
1047                method: Some(crate::JSONEngineMethod::Get),
1048                params: Some(vec![SearchUrlParam {
1049                    name: "search-form-name".to_string(),
1050                    value: Some("search-form-value".to_string()),
1051                    enterprise_value: None,
1052                    experiment_config: None,
1053                }]),
1054                ..Default::default()
1055            }),
1056            visual_search: Some(JSONEngineUrl {
1057                base: Some("https://example.com/visual-search-variant".to_string()),
1058                method: Some(JSONEngineMethod::Get),
1059                params: Some(vec![SearchUrlParam {
1060                    name: "visual-search-variant-name".to_string(),
1061                    value: Some("visual-search-variant-value".to_string()),
1062                    enterprise_value: None,
1063                    experiment_config: None,
1064                }]),
1065                search_term_param_name: Some("url_variant".to_string()),
1066                display_name_map: Some(HashMap::from([
1067                    ("default".to_string(), "Visual Search Variant".to_string()),
1068                    // en-GB locale with unique display name
1069                    (
1070                        "en-GB".to_string(),
1071                        "Visual Search Variant en-GB".to_string(),
1072                    ),
1073                ])),
1074                is_new_until: Some("2096-02-02".to_string()),
1075                ..Default::default()
1076            }),
1077        }),
1078        sub_variants: vec![],
1079    });
1080
1081    #[test]
1082    fn test_from_configuration_details_merges_variants() {
1083        let result = SearchEngineDefinition::from_configuration_details(
1084            &SearchUserEnvironment {
1085                locale: "fi".into(),
1086                ..Default::default()
1087            },
1088            "test",
1089            Lazy::force(&ENGINE_BASE).clone(),
1090            &ENGINE_VARIANT,
1091            &None,
1092        );
1093
1094        assert_eq!(
1095            result,
1096            SearchEngineDefinition {
1097                aliases: vec!["foo".to_string(), "bar".to_string()],
1098                charset: "ISO-8859-15".to_string(),
1099                classification: SearchEngineClassification::Unknown,
1100                identifier: "test".to_string(),
1101                is_new_until: Some("2063-04-05".to_string()),
1102                partner_code: "trek".to_string(),
1103                name: "Test".to_string(),
1104                optional: true,
1105                order_hint: None,
1106                telemetry_suffix: "star".to_string(),
1107                urls: SearchEngineUrls {
1108                    search: SearchEngineUrl {
1109                        base: "https://example.com/variant".to_string(),
1110                        method: "GET".to_string(),
1111                        params: vec![SearchUrlParam {
1112                            name: "variant".to_string(),
1113                            value: Some("test variant".to_string()),
1114                            enterprise_value: None,
1115                            experiment_config: None,
1116                        }],
1117                        search_term_param_name: Some("ship".to_string()),
1118                        ..Default::default()
1119                    },
1120                    suggestions: Some(SearchEngineUrl {
1121                        base: "https://example.com/suggestions-variant".to_string(),
1122                        method: "GET".to_string(),
1123                        params: vec![SearchUrlParam {
1124                            name: "suggest-variant".to_string(),
1125                            value: Some("sugg test variant".to_string()),
1126                            enterprise_value: None,
1127                            experiment_config: None,
1128                        }],
1129                        search_term_param_name: Some("variant".to_string()),
1130                        ..Default::default()
1131                    }),
1132                    trending: Some(SearchEngineUrl {
1133                        base: "https://example.com/trending-variant".to_string(),
1134                        method: "GET".to_string(),
1135                        params: vec![SearchUrlParam {
1136                            name: "trend-variant".to_string(),
1137                            value: Some("trend test variant".to_string()),
1138                            enterprise_value: None,
1139                            experiment_config: None,
1140                        }],
1141                        search_term_param_name: Some("trend".to_string()),
1142                        exclude_partner_code_from_telemetry: true,
1143                        ..Default::default()
1144                    }),
1145                    search_form: Some(SearchEngineUrl {
1146                        base: "https://example.com/search_form".to_string(),
1147                        method: "GET".to_string(),
1148                        params: vec![SearchUrlParam {
1149                            name: "search-form-name".to_string(),
1150                            value: Some("search-form-value".to_string()),
1151                            enterprise_value: None,
1152                            experiment_config: None,
1153                        }],
1154                        ..Default::default()
1155                    }),
1156                    visual_search: Some(SearchEngineUrl {
1157                        base: "https://example.com/visual-search-variant".to_string(),
1158                        method: "GET".to_string(),
1159                        params: vec![SearchUrlParam {
1160                            name: "visual-search-variant-name".to_string(),
1161                            value: Some("visual-search-variant-value".to_string()),
1162                            enterprise_value: None,
1163                            experiment_config: None,
1164                        }],
1165                        search_term_param_name: Some("url_variant".to_string()),
1166                        // Should be the "default" display name since the "fi"
1167                        // locale isn't present in `display_name_map`.
1168                        display_name: Some("Visual Search Variant".to_string()),
1169                        is_new_until: Some("2096-02-02".to_string()),
1170                        exclude_partner_code_from_telemetry: false,
1171                    }),
1172                },
1173                click_url: None
1174            }
1175        )
1176    }
1177
1178    #[test]
1179    fn test_from_configuration_details_merges_variants_locale_match() {
1180        let result = SearchEngineDefinition::from_configuration_details(
1181            &SearchUserEnvironment {
1182                // en-GB locale
1183                locale: "en-GB".into(),
1184                ..Default::default()
1185            },
1186            "test",
1187            Lazy::force(&ENGINE_BASE).clone(),
1188            &ENGINE_VARIANT,
1189            &None,
1190        );
1191
1192        assert_eq!(
1193            result,
1194            SearchEngineDefinition {
1195                aliases: vec!["foo".to_string(), "bar".to_string()],
1196                charset: "ISO-8859-15".to_string(),
1197                classification: SearchEngineClassification::Unknown,
1198                identifier: "test".to_string(),
1199                is_new_until: Some("2063-04-05".to_string()),
1200                partner_code: "trek".to_string(),
1201                name: "Test".to_string(),
1202                optional: true,
1203                order_hint: None,
1204                telemetry_suffix: "star".to_string(),
1205                urls: SearchEngineUrls {
1206                    search: SearchEngineUrl {
1207                        base: "https://example.com/variant".to_string(),
1208                        method: "GET".to_string(),
1209                        params: vec![SearchUrlParam {
1210                            name: "variant".to_string(),
1211                            value: Some("test variant".to_string()),
1212                            enterprise_value: None,
1213                            experiment_config: None,
1214                        }],
1215                        search_term_param_name: Some("ship".to_string()),
1216                        ..Default::default()
1217                    },
1218                    suggestions: Some(SearchEngineUrl {
1219                        base: "https://example.com/suggestions-variant".to_string(),
1220                        method: "GET".to_string(),
1221                        params: vec![SearchUrlParam {
1222                            name: "suggest-variant".to_string(),
1223                            value: Some("sugg test variant".to_string()),
1224                            enterprise_value: None,
1225                            experiment_config: None,
1226                        }],
1227                        search_term_param_name: Some("variant".to_string()),
1228                        ..Default::default()
1229                    }),
1230                    trending: Some(SearchEngineUrl {
1231                        base: "https://example.com/trending-variant".to_string(),
1232                        method: "GET".to_string(),
1233                        params: vec![SearchUrlParam {
1234                            name: "trend-variant".to_string(),
1235                            value: Some("trend test variant".to_string()),
1236                            enterprise_value: None,
1237                            experiment_config: None,
1238                        }],
1239                        search_term_param_name: Some("trend".to_string()),
1240                        exclude_partner_code_from_telemetry: true,
1241                        ..Default::default()
1242                    }),
1243                    search_form: Some(SearchEngineUrl {
1244                        base: "https://example.com/search_form".to_string(),
1245                        method: "GET".to_string(),
1246                        params: vec![SearchUrlParam {
1247                            name: "search-form-name".to_string(),
1248                            value: Some("search-form-value".to_string()),
1249                            enterprise_value: None,
1250                            experiment_config: None,
1251                        }],
1252                        ..Default::default()
1253                    }),
1254                    visual_search: Some(SearchEngineUrl {
1255                        base: "https://example.com/visual-search-variant".to_string(),
1256                        method: "GET".to_string(),
1257                        params: vec![SearchUrlParam {
1258                            name: "visual-search-variant-name".to_string(),
1259                            value: Some("visual-search-variant-value".to_string()),
1260                            enterprise_value: None,
1261                            experiment_config: None,
1262                        }],
1263                        search_term_param_name: Some("url_variant".to_string()),
1264                        // Should be the en-GB display name since the "en-GB"
1265                        // locale is present in `display_name_map`.
1266                        display_name: Some("Visual Search Variant en-GB".to_string()),
1267                        is_new_until: Some("2096-02-02".to_string()),
1268                        exclude_partner_code_from_telemetry: false,
1269                    }),
1270                },
1271                click_url: None
1272            }
1273        )
1274    }
1275
1276    static ENGINE_SUBVARIANT: Lazy<JSONEngineVariant> = Lazy::new(|| JSONEngineVariant {
1277        environment: JSONVariantEnvironment {
1278            all_regions_and_locales: true,
1279            ..Default::default()
1280        },
1281        is_new_until: Some("2063-04-05".to_string()),
1282        optional: true,
1283        partner_code: Some("trek2".to_string()),
1284        telemetry_suffix: Some("star2".to_string()),
1285        urls: Some(JSONEngineUrls {
1286            search: Some(JSONEngineUrl {
1287                base: Some("https://example.com/subvariant".to_string()),
1288                method: Some(JSONEngineMethod::Get),
1289                params: Some(vec![SearchUrlParam {
1290                    name: "subvariant".to_string(),
1291                    value: Some("test subvariant".to_string()),
1292                    enterprise_value: None,
1293                    experiment_config: None,
1294                }]),
1295                search_term_param_name: Some("shuttle".to_string()),
1296                ..Default::default()
1297            }),
1298            suggestions: Some(JSONEngineUrl {
1299                base: Some("https://example.com/suggestions-subvariant".to_string()),
1300                method: Some(JSONEngineMethod::Get),
1301                params: Some(vec![SearchUrlParam {
1302                    name: "suggest-subvariant".to_string(),
1303                    value: Some("sugg test subvariant".to_string()),
1304                    enterprise_value: None,
1305                    experiment_config: None,
1306                }]),
1307                search_term_param_name: Some("subvariant".to_string()),
1308                exclude_partner_code_from_telemetry: true,
1309                ..Default::default()
1310            }),
1311            trending: Some(JSONEngineUrl {
1312                base: Some("https://example.com/trending-subvariant".to_string()),
1313                method: Some(JSONEngineMethod::Get),
1314                params: Some(vec![SearchUrlParam {
1315                    name: "trend-subvariant".to_string(),
1316                    value: Some("trend test subvariant".to_string()),
1317                    enterprise_value: None,
1318                    experiment_config: None,
1319                }]),
1320                search_term_param_name: Some("subtrend".to_string()),
1321                ..Default::default()
1322            }),
1323            search_form: Some(JSONEngineUrl {
1324                base: Some("https://example.com/search-form-subvariant".to_string()),
1325                method: Some(crate::JSONEngineMethod::Get),
1326                params: Some(vec![SearchUrlParam {
1327                    name: "search-form-subvariant".to_string(),
1328                    value: Some("search form subvariant".to_string()),
1329                    enterprise_value: None,
1330                    experiment_config: None,
1331                }]),
1332                ..Default::default()
1333            }),
1334            visual_search: Some(JSONEngineUrl {
1335                base: Some("https://example.com/visual-search-subvariant".to_string()),
1336                method: Some(JSONEngineMethod::Get),
1337                params: Some(vec![SearchUrlParam {
1338                    name: "visual-search-subvariant-name".to_string(),
1339                    value: Some("visual-search-subvariant-value".to_string()),
1340                    enterprise_value: None,
1341                    experiment_config: None,
1342                }]),
1343                search_term_param_name: Some("url_subvariant".to_string()),
1344                display_name_map: Some(HashMap::from([
1345                    (
1346                        "default".to_string(),
1347                        "Visual Search Subvariant".to_string(),
1348                    ),
1349                    // en-GB locale with unique display name
1350                    (
1351                        "en-GB".to_string(),
1352                        "Visual Search Subvariant en-GB".to_string(),
1353                    ),
1354                ])),
1355                is_new_until: Some("2097-03-03".to_string()),
1356                ..Default::default()
1357            }),
1358        }),
1359        sub_variants: vec![],
1360    });
1361
1362    #[test]
1363    fn test_from_configuration_details_merges_sub_variants() {
1364        let result = SearchEngineDefinition::from_configuration_details(
1365            &SearchUserEnvironment {
1366                locale: "fi".into(),
1367                ..Default::default()
1368            },
1369            "test",
1370            Lazy::force(&ENGINE_BASE).clone(),
1371            &ENGINE_VARIANT,
1372            &Some(ENGINE_SUBVARIANT.clone()),
1373        );
1374
1375        assert_eq!(
1376            result,
1377            SearchEngineDefinition {
1378                aliases: vec!["foo".to_string(), "bar".to_string()],
1379                charset: "ISO-8859-15".to_string(),
1380                classification: SearchEngineClassification::Unknown,
1381                identifier: "test".to_string(),
1382                is_new_until: Some("2063-04-05".to_string()),
1383                partner_code: "trek2".to_string(),
1384                name: "Test".to_string(),
1385                optional: true,
1386                order_hint: None,
1387                telemetry_suffix: "star2".to_string(),
1388                urls: SearchEngineUrls {
1389                    search: SearchEngineUrl {
1390                        base: "https://example.com/subvariant".to_string(),
1391                        method: "GET".to_string(),
1392                        params: vec![SearchUrlParam {
1393                            name: "subvariant".to_string(),
1394                            value: Some("test subvariant".to_string()),
1395                            enterprise_value: None,
1396                            experiment_config: None,
1397                        }],
1398                        search_term_param_name: Some("shuttle".to_string()),
1399                        ..Default::default()
1400                    },
1401                    suggestions: Some(SearchEngineUrl {
1402                        base: "https://example.com/suggestions-subvariant".to_string(),
1403                        method: "GET".to_string(),
1404                        params: vec![SearchUrlParam {
1405                            name: "suggest-subvariant".to_string(),
1406                            value: Some("sugg test subvariant".to_string()),
1407                            enterprise_value: None,
1408                            experiment_config: None,
1409                        }],
1410                        search_term_param_name: Some("subvariant".to_string()),
1411                        exclude_partner_code_from_telemetry: true,
1412                        ..Default::default()
1413                    }),
1414                    trending: Some(SearchEngineUrl {
1415                        base: "https://example.com/trending-subvariant".to_string(),
1416                        method: "GET".to_string(),
1417                        params: vec![SearchUrlParam {
1418                            name: "trend-subvariant".to_string(),
1419                            value: Some("trend test subvariant".to_string()),
1420                            enterprise_value: None,
1421                            experiment_config: None,
1422                        }],
1423                        search_term_param_name: Some("subtrend".to_string()),
1424                        ..Default::default()
1425                    }),
1426                    search_form: Some(SearchEngineUrl {
1427                        base: "https://example.com/search-form-subvariant".to_string(),
1428                        method: "GET".to_string(),
1429                        params: vec![SearchUrlParam {
1430                            name: "search-form-subvariant".to_string(),
1431                            value: Some("search form subvariant".to_string()),
1432                            enterprise_value: None,
1433                            experiment_config: None,
1434                        }],
1435                        ..Default::default()
1436                    }),
1437                    visual_search: Some(SearchEngineUrl {
1438                        base: "https://example.com/visual-search-subvariant".to_string(),
1439                        method: "GET".to_string(),
1440                        params: vec![SearchUrlParam {
1441                            name: "visual-search-subvariant-name".to_string(),
1442                            value: Some("visual-search-subvariant-value".to_string()),
1443                            enterprise_value: None,
1444                            experiment_config: None,
1445                        }],
1446                        search_term_param_name: Some("url_subvariant".to_string()),
1447                        // Should be the "default" display name since the "fi"
1448                        // locale isn't present in `display_name_map`.
1449                        display_name: Some("Visual Search Subvariant".to_string()),
1450                        is_new_until: Some("2097-03-03".to_string()),
1451                        exclude_partner_code_from_telemetry: false,
1452                    }),
1453                },
1454                click_url: None
1455            }
1456        )
1457    }
1458
1459    #[test]
1460    fn test_from_configuration_details_merges_sub_variants_locale_match() {
1461        let result = SearchEngineDefinition::from_configuration_details(
1462            &SearchUserEnvironment {
1463                // en-GB locale
1464                locale: "en-GB".into(),
1465                ..Default::default()
1466            },
1467            "test",
1468            Lazy::force(&ENGINE_BASE).clone(),
1469            &ENGINE_VARIANT,
1470            &Some(ENGINE_SUBVARIANT.clone()),
1471        );
1472
1473        assert_eq!(
1474            result,
1475            SearchEngineDefinition {
1476                aliases: vec!["foo".to_string(), "bar".to_string()],
1477                charset: "ISO-8859-15".to_string(),
1478                classification: SearchEngineClassification::Unknown,
1479                identifier: "test".to_string(),
1480                is_new_until: Some("2063-04-05".to_string()),
1481                partner_code: "trek2".to_string(),
1482                name: "Test".to_string(),
1483                optional: true,
1484                order_hint: None,
1485                telemetry_suffix: "star2".to_string(),
1486                urls: SearchEngineUrls {
1487                    search: SearchEngineUrl {
1488                        base: "https://example.com/subvariant".to_string(),
1489                        method: "GET".to_string(),
1490                        params: vec![SearchUrlParam {
1491                            name: "subvariant".to_string(),
1492                            value: Some("test subvariant".to_string()),
1493                            enterprise_value: None,
1494                            experiment_config: None,
1495                        }],
1496                        search_term_param_name: Some("shuttle".to_string()),
1497                        ..Default::default()
1498                    },
1499                    suggestions: Some(SearchEngineUrl {
1500                        base: "https://example.com/suggestions-subvariant".to_string(),
1501                        method: "GET".to_string(),
1502                        params: vec![SearchUrlParam {
1503                            name: "suggest-subvariant".to_string(),
1504                            value: Some("sugg test subvariant".to_string()),
1505                            enterprise_value: None,
1506                            experiment_config: None,
1507                        }],
1508                        search_term_param_name: Some("subvariant".to_string()),
1509                        exclude_partner_code_from_telemetry: true,
1510                        ..Default::default()
1511                    }),
1512                    trending: Some(SearchEngineUrl {
1513                        base: "https://example.com/trending-subvariant".to_string(),
1514                        method: "GET".to_string(),
1515                        params: vec![SearchUrlParam {
1516                            name: "trend-subvariant".to_string(),
1517                            value: Some("trend test subvariant".to_string()),
1518                            enterprise_value: None,
1519                            experiment_config: None,
1520                        }],
1521                        search_term_param_name: Some("subtrend".to_string()),
1522                        ..Default::default()
1523                    }),
1524                    search_form: Some(SearchEngineUrl {
1525                        base: "https://example.com/search-form-subvariant".to_string(),
1526                        method: "GET".to_string(),
1527                        params: vec![SearchUrlParam {
1528                            name: "search-form-subvariant".to_string(),
1529                            value: Some("search form subvariant".to_string()),
1530                            enterprise_value: None,
1531                            experiment_config: None,
1532                        }],
1533                        ..Default::default()
1534                    }),
1535                    visual_search: Some(SearchEngineUrl {
1536                        base: "https://example.com/visual-search-subvariant".to_string(),
1537                        method: "GET".to_string(),
1538                        params: vec![SearchUrlParam {
1539                            name: "visual-search-subvariant-name".to_string(),
1540                            value: Some("visual-search-subvariant-value".to_string()),
1541                            enterprise_value: None,
1542                            experiment_config: None,
1543                        }],
1544                        search_term_param_name: Some("url_subvariant".to_string()),
1545                        // Should be the en-GB display name since the "en-GB"
1546                        // locale is present in `display_name_map`.
1547                        display_name: Some("Visual Search Subvariant en-GB".to_string()),
1548                        is_new_until: Some("2097-03-03".to_string()),
1549                        exclude_partner_code_from_telemetry: false,
1550                    }),
1551                },
1552                click_url: None
1553            }
1554        )
1555    }
1556
1557    static ENGINES_LIST: Lazy<Vec<SearchEngineDefinition>> = Lazy::new(|| {
1558        vec![
1559            SearchEngineDefinition {
1560                identifier: "engine1".to_string(),
1561                name: "Test".to_string(),
1562                urls: SearchEngineUrls {
1563                    search: SearchEngineUrl {
1564                        base: "https://example.com".to_string(),
1565                        ..Default::default()
1566                    },
1567                    ..Default::default()
1568                },
1569                ..Default::default()
1570            },
1571            SearchEngineDefinition {
1572                identifier: "engine2".to_string(),
1573                name: "Test 2".to_string(),
1574                urls: SearchEngineUrls {
1575                    search: SearchEngineUrl {
1576                        base: "https://example.com/2".to_string(),
1577                        ..Default::default()
1578                    },
1579                    ..Default::default()
1580                },
1581                ..Default::default()
1582            },
1583            SearchEngineDefinition {
1584                identifier: "engine3".to_string(),
1585                name: "Test 3".to_string(),
1586                urls: SearchEngineUrls {
1587                    search: SearchEngineUrl {
1588                        base: "https://example.com/3".to_string(),
1589                        ..Default::default()
1590                    },
1591                    ..Default::default()
1592                },
1593                ..Default::default()
1594            },
1595            SearchEngineDefinition {
1596                identifier: "engine4wildcardmatch".to_string(),
1597                name: "Test 4".to_string(),
1598                urls: SearchEngineUrls {
1599                    search: SearchEngineUrl {
1600                        base: "https://example.com/4".to_string(),
1601                        ..Default::default()
1602                    },
1603                    ..Default::default()
1604                },
1605                ..Default::default()
1606            },
1607        ]
1608    });
1609
1610    #[test]
1611    fn test_determine_default_engines_returns_global_default() {
1612        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1613            &ENGINES_LIST,
1614            Some(JSONDefaultEnginesRecord {
1615                global_default: "engine2".to_string(),
1616                global_default_private: String::new(),
1617                specific_defaults: Vec::new(),
1618            }),
1619            &SearchUserEnvironment {
1620                locale: "fi".into(),
1621                ..Default::default()
1622            },
1623        );
1624
1625        assert_eq!(
1626            default_engine_id.unwrap(),
1627            "engine2",
1628            "Should have returned the global default engine"
1629        );
1630        assert!(
1631            default_engine_private_id.is_none(),
1632            "Should not have returned an id for the private engine"
1633        );
1634
1635        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1636            &ENGINES_LIST,
1637            Some(JSONDefaultEnginesRecord {
1638                global_default: "engine2".to_string(),
1639                global_default_private: String::new(),
1640                specific_defaults: vec![JSONSpecificDefaultRecord {
1641                    default: "engine1".to_string(),
1642                    default_private: String::new(),
1643                    environment: JSONVariantEnvironment {
1644                        locales: vec!["en-GB".to_string()],
1645                        ..Default::default()
1646                    },
1647                }],
1648            }),
1649            &SearchUserEnvironment {
1650                locale: "fi".into(),
1651                ..Default::default()
1652            },
1653        );
1654
1655        assert_eq!(
1656            default_engine_id.unwrap(),
1657            "engine2",
1658            "Should have returned the global default engine when no specific defaults environments match"
1659        );
1660        assert!(
1661            default_engine_private_id.is_none(),
1662            "Should not have returned an id for the private engine"
1663        );
1664
1665        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1666            &ENGINES_LIST,
1667            Some(JSONDefaultEnginesRecord {
1668                global_default: "engine2".to_string(),
1669                global_default_private: String::new(),
1670                specific_defaults: vec![JSONSpecificDefaultRecord {
1671                    default: "engine1".to_string(),
1672                    default_private: String::new(),
1673                    environment: JSONVariantEnvironment {
1674                        locales: vec!["fi".to_string()],
1675                        ..Default::default()
1676                    },
1677                }],
1678            }),
1679            &SearchUserEnvironment {
1680                locale: "fi".into(),
1681                ..Default::default()
1682            },
1683        );
1684
1685        assert_eq!(
1686            default_engine_id.unwrap(),
1687            "engine1",
1688            "Should have returned the specific default when environments match"
1689        );
1690        assert!(
1691            default_engine_private_id.is_none(),
1692            "Should not have returned an id for the private engine"
1693        );
1694
1695        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1696            &ENGINES_LIST,
1697            Some(JSONDefaultEnginesRecord {
1698                global_default: "engine2".to_string(),
1699                global_default_private: String::new(),
1700                specific_defaults: vec![JSONSpecificDefaultRecord {
1701                    default: "engine4*".to_string(),
1702                    default_private: String::new(),
1703                    environment: JSONVariantEnvironment {
1704                        locales: vec!["fi".to_string()],
1705                        ..Default::default()
1706                    },
1707                }],
1708            }),
1709            &SearchUserEnvironment {
1710                locale: "fi".into(),
1711                ..Default::default()
1712            },
1713        );
1714
1715        assert_eq!(
1716            default_engine_id.unwrap(),
1717            "engine4wildcardmatch",
1718            "Should have returned the specific default when using a wildcard match"
1719        );
1720        assert!(
1721            default_engine_private_id.is_none(),
1722            "Should not have returned an id for the private engine"
1723        );
1724
1725        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1726            &ENGINES_LIST,
1727            Some(JSONDefaultEnginesRecord {
1728                global_default: "engine2".to_string(),
1729                global_default_private: String::new(),
1730                specific_defaults: vec![
1731                    JSONSpecificDefaultRecord {
1732                        default: "engine4*".to_string(),
1733                        default_private: String::new(),
1734                        environment: JSONVariantEnvironment {
1735                            locales: vec!["fi".to_string()],
1736                            ..Default::default()
1737                        },
1738                    },
1739                    JSONSpecificDefaultRecord {
1740                        default: "engine3".to_string(),
1741                        default_private: String::new(),
1742                        environment: JSONVariantEnvironment {
1743                            locales: vec!["fi".to_string()],
1744                            ..Default::default()
1745                        },
1746                    },
1747                ],
1748            }),
1749            &SearchUserEnvironment {
1750                locale: "fi".into(),
1751                ..Default::default()
1752            },
1753        );
1754
1755        assert_eq!(
1756            default_engine_id.unwrap(),
1757            "engine3",
1758            "Should have returned the last specific default when multiple environments match"
1759        );
1760        assert!(
1761            default_engine_private_id.is_none(),
1762            "Should not have returned an id for the private engine"
1763        );
1764    }
1765
1766    #[test]
1767    fn test_determine_default_engines_returns_global_default_private() {
1768        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1769            &ENGINES_LIST,
1770            Some(JSONDefaultEnginesRecord {
1771                global_default: "engine2".to_string(),
1772                global_default_private: "engine3".to_string(),
1773                specific_defaults: Vec::new(),
1774            }),
1775            &SearchUserEnvironment {
1776                ..Default::default()
1777            },
1778        );
1779
1780        assert_eq!(
1781            default_engine_id.unwrap(),
1782            "engine2",
1783            "Should have returned the global default engine"
1784        );
1785        assert_eq!(
1786            default_engine_private_id.unwrap(),
1787            "engine3",
1788            "Should have returned the global default engine for private mode"
1789        );
1790
1791        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1792            &ENGINES_LIST,
1793            Some(JSONDefaultEnginesRecord {
1794                global_default: "engine2".to_string(),
1795                global_default_private: "engine3".to_string(),
1796                specific_defaults: vec![JSONSpecificDefaultRecord {
1797                    default: String::new(),
1798                    default_private: "engine1".to_string(),
1799                    environment: JSONVariantEnvironment {
1800                        locales: vec!["en-GB".to_string()],
1801                        ..Default::default()
1802                    },
1803                }],
1804            }),
1805            &SearchUserEnvironment {
1806                locale: "fi".into(),
1807                ..Default::default()
1808            },
1809        );
1810
1811        assert_eq!(
1812            default_engine_id.unwrap(),
1813            "engine2",
1814            "Should have returned the global default engine when no specific defaults environments match"
1815        );
1816        assert_eq!(
1817            default_engine_private_id.unwrap(),
1818            "engine3",
1819            "Should have returned the global default engine for private mode when no specific defaults environments match"
1820        );
1821
1822        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1823            &ENGINES_LIST,
1824            Some(JSONDefaultEnginesRecord {
1825                global_default: "engine2".to_string(),
1826                global_default_private: "engine3".to_string(),
1827                specific_defaults: vec![JSONSpecificDefaultRecord {
1828                    default: String::new(),
1829                    default_private: "engine1".to_string(),
1830                    environment: JSONVariantEnvironment {
1831                        locales: vec!["fi".to_string()],
1832                        ..Default::default()
1833                    },
1834                }],
1835            }),
1836            &SearchUserEnvironment {
1837                locale: "fi".into(),
1838                ..Default::default()
1839            },
1840        );
1841
1842        assert_eq!(
1843            default_engine_id.unwrap(),
1844            "engine2",
1845            "Should have returned the global default engine when specific environments match which override the private global default (and not the global default)."
1846        );
1847        assert_eq!(
1848            default_engine_private_id.unwrap(),
1849            "engine1",
1850            "Should have returned the specific default engine for private mode when environments match"
1851        );
1852
1853        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1854            &ENGINES_LIST,
1855            Some(JSONDefaultEnginesRecord {
1856                global_default: "engine2".to_string(),
1857                global_default_private: String::new(),
1858                specific_defaults: vec![JSONSpecificDefaultRecord {
1859                    default: String::new(),
1860                    default_private: "engine4*".to_string(),
1861                    environment: JSONVariantEnvironment {
1862                        locales: vec!["fi".to_string()],
1863                        ..Default::default()
1864                    },
1865                }],
1866            }),
1867            &SearchUserEnvironment {
1868                locale: "fi".into(),
1869                ..Default::default()
1870            },
1871        );
1872
1873        assert_eq!(
1874            default_engine_id.unwrap(),
1875            "engine2",
1876            "Should have returned the global default engine when specific environments match which override the private global default (and not the global default)"
1877        );
1878        assert_eq!(
1879            default_engine_private_id.unwrap(),
1880            "engine4wildcardmatch",
1881            "Should have returned the specific default for private mode when using a wildcard match"
1882        );
1883    }
1884
1885    #[test]
1886    fn test_locale_matched_exactly() {
1887        let mut user_env = SearchUserEnvironment {
1888            locale: "en-CA".into(),
1889            ..Default::default()
1890        };
1891        negotiate_languages(&mut user_env, &["en-CA".to_string(), "fr".to_string()]);
1892        assert_eq!(
1893            user_env.locale, "en-CA",
1894            "Should return user locale unchanged if in available locales"
1895        );
1896    }
1897
1898    #[test]
1899    fn test_locale_fallback_to_base_locale() {
1900        let mut user_env = SearchUserEnvironment {
1901            locale: "de-AT".into(),
1902            ..Default::default()
1903        };
1904        negotiate_languages(&mut user_env, &["de".to_string()]);
1905        assert_eq!(
1906            user_env.locale, "de",
1907            "Should fallback to base locale if base is in available locales"
1908        );
1909    }
1910
1911    static ENGLISH_LOCALES: &[&str] = &["en-AU", "en-IE", "en-RU", "en-ZA"];
1912
1913    #[test]
1914    fn test_english_locales_fallbacks_to_en_us() {
1915        for user_locale in ENGLISH_LOCALES {
1916            let mut user_env = SearchUserEnvironment {
1917                locale: user_locale.to_string(),
1918                ..Default::default()
1919            };
1920            negotiate_languages(&mut user_env, &["en-US".to_string()]);
1921            assert_eq!(
1922                user_env.locale, "en-us",
1923                "Should remap {} to en-us when en-us is available",
1924                user_locale
1925            );
1926        }
1927    }
1928
1929    #[test]
1930    fn test_locale_unmatched() {
1931        let mut user_env = SearchUserEnvironment {
1932            locale: "fr-CA".into(),
1933            ..Default::default()
1934        };
1935        negotiate_languages(&mut user_env, &["de".to_string(), "en-US".to_string()]);
1936        assert_eq!(
1937            user_env.locale, "fr-CA",
1938            "Should leave locale unchanged if no match or english locale fallback is not found"
1939        );
1940    }
1941}