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, JSONEngineRecord, JSONEngineUrl,
11    JSONEngineUrls, JSONEngineVariant, JSONSearchConfigurationRecords, RefinedSearchConfig,
12    SearchEngineDefinition, SearchEngineUrl, SearchEngineUrls, SearchUserEnvironment,
13};
14use crate::{sort_helpers, JSONAvailableLocalesRecord, JSONEngineOrdersRecord};
15use remote_settings::RemoteSettingsRecord;
16use std::collections::HashSet;
17
18impl From<JSONEngineUrl> for SearchEngineUrl {
19    fn from(url: JSONEngineUrl) -> Self {
20        Self {
21            base: url.base.unwrap_or_default(),
22            method: url.method.unwrap_or_default().as_str().to_string(),
23            params: url.params.unwrap_or_default(),
24            search_term_param_name: url.search_term_param_name,
25        }
26    }
27}
28
29impl SearchEngineUrl {
30    fn merge(&mut self, preferred: &JSONEngineUrl) {
31        if let Some(base) = &preferred.base {
32            self.base = base.clone();
33        }
34        if let Some(method) = &preferred.method {
35            self.method = method.as_str().to_string();
36        }
37        if let Some(params) = &preferred.params {
38            self.params = params.clone();
39        }
40        if let Some(search_term_param_name) = &preferred.search_term_param_name {
41            self.search_term_param_name = Some(search_term_param_name.clone());
42        }
43    }
44}
45
46impl From<JSONEngineUrls> for SearchEngineUrls {
47    fn from(urls: JSONEngineUrls) -> Self {
48        Self {
49            search: urls.search.unwrap_or_default().into(),
50            suggestions: urls.suggestions.map(|suggestions| suggestions.into()),
51            trending: urls.trending.map(|trending| trending.into()),
52            search_form: urls.search_form.map(|search_form| search_form.into()),
53        }
54    }
55}
56
57impl SearchEngineUrls {
58    fn merge(&mut self, preferred: &JSONEngineUrls) {
59        if let Some(search_url) = &preferred.search {
60            self.search.merge(search_url);
61        }
62        if let Some(suggestions_url) = &preferred.suggestions {
63            match &mut self.suggestions {
64                Some(suggestion) => suggestion.merge(suggestions_url),
65                None => self.suggestions = Some(suggestions_url.clone().into()),
66            };
67        }
68        if let Some(trending_url) = &preferred.trending {
69            match &mut self.trending {
70                Some(trend) => trend.merge(trending_url),
71                None => self.trending = Some(trending_url.clone().into()),
72            };
73        }
74        if let Some(search_form_url) = &preferred.search_form {
75            match &mut self.search_form {
76                Some(search_form) => search_form.merge(search_form_url),
77                None => self.search_form = Some(search_form_url.clone().into()),
78            };
79        }
80    }
81}
82
83impl SearchEngineDefinition {
84    fn merge_variant(&mut self, variant: &JSONEngineVariant) {
85        if !self.optional {
86            self.optional = variant.optional;
87        }
88        if let Some(partner_code) = &variant.partner_code {
89            self.partner_code = partner_code.clone();
90        }
91        if let Some(telemetry_suffix) = &variant.telemetry_suffix {
92            self.telemetry_suffix = telemetry_suffix.clone();
93        }
94        if let Some(urls) = &variant.urls {
95            self.urls.merge(urls);
96        }
97        if let Some(is_new_until) = &variant.is_new_until {
98            self.is_new_until = Some(is_new_until.clone());
99        }
100    }
101
102    fn merge_override(&mut self, override_record: &JSONOverridesRecord) {
103        self.partner_code = override_record.partner_code.clone();
104        self.urls.merge(&override_record.urls);
105        self.click_url = Some(override_record.click_url.clone());
106
107        if let Some(telemetry_suffix) = &override_record.telemetry_suffix {
108            self.telemetry_suffix = telemetry_suffix.clone();
109        }
110    }
111
112    pub(crate) fn from_configuration_details(
113        identifier: &str,
114        base: JSONEngineBase,
115        variant: &JSONEngineVariant,
116        sub_variant: &Option<JSONEngineVariant>,
117    ) -> SearchEngineDefinition {
118        let mut engine_definition = SearchEngineDefinition {
119            aliases: base.aliases.unwrap_or_default(),
120            charset: base.charset.unwrap_or_else(|| "UTF-8".to_string()),
121            classification: base.classification,
122            identifier: identifier.to_string(),
123            name: base.name,
124            optional: variant.optional,
125            order_hint: None,
126            partner_code: base.partner_code.unwrap_or_default(),
127            telemetry_suffix: String::new(),
128            urls: base.urls.into(),
129            click_url: None,
130            is_new_until: None,
131        };
132
133        engine_definition.merge_variant(variant);
134        if let Some(sub_variant) = sub_variant {
135            engine_definition.merge_variant(sub_variant);
136        }
137
138        engine_definition
139    }
140}
141
142pub(crate) struct FilterRecordsResult {
143    engines: Vec<SearchEngineDefinition>,
144    default_engines_record: Option<JSONDefaultEnginesRecord>,
145    engine_orders_record: Option<JSONEngineOrdersRecord>,
146}
147
148pub(crate) trait Filter {
149    fn filter_records(
150        &self,
151        user_environment: &mut SearchUserEnvironment,
152        overrides: Option<Vec<JSONOverridesRecord>>,
153    ) -> Result<FilterRecordsResult, Error>;
154}
155
156fn apply_overrides(engines: &mut [SearchEngineDefinition], overrides: &[JSONOverridesRecord]) {
157    for override_record in overrides {
158        for engine in engines.iter_mut() {
159            if engine.identifier == override_record.identifier {
160                engine.merge_override(override_record);
161            }
162        }
163    }
164}
165
166fn negotiate_languages(user_environment: &mut SearchUserEnvironment, available_locales: &[String]) {
167    let user_locale = user_environment.locale.to_lowercase();
168
169    let available_locales_set: HashSet<String> = available_locales
170        .iter()
171        .map(|locale| locale.to_lowercase())
172        .collect();
173
174    if available_locales_set.contains(&user_locale) {
175        return;
176    }
177    if user_locale.starts_with("en-") {
178        user_environment.locale = "en-us".to_string();
179        return;
180    }
181    if let Some(index) = user_locale.find('-') {
182        let base_locale = &user_locale[..index];
183        if available_locales_set.contains(base_locale) {
184            user_environment.locale = base_locale.to_string();
185        }
186    }
187}
188
189impl Filter for Vec<RemoteSettingsRecord> {
190    fn filter_records(
191        &self,
192        user_environment: &mut SearchUserEnvironment,
193        overrides: Option<Vec<JSONOverridesRecord>>,
194    ) -> Result<FilterRecordsResult, Error> {
195        let mut available_locales = Vec::new();
196        for record in self {
197            if let Some(val) = record.fields.get("recordType") {
198                if *val == "availableLocales" {
199                    let stringified = serde_json::to_string(&record.fields)?;
200                    let locales_record: Option<JSONAvailableLocalesRecord> =
201                        serde_json::from_str(&stringified)?;
202                    available_locales = locales_record.unwrap().locales;
203                }
204            }
205        }
206        negotiate_languages(user_environment, &available_locales);
207
208        let mut engines = Vec::new();
209        let mut default_engines_record = None;
210        let mut engine_orders_record = None;
211
212        for record in self {
213            // TODO: Bug 1947241 - Find a way to avoid having to serialise the records
214            // back to strings and then deserialise them into the records that we want.
215            let stringified = serde_json::to_string(&record.fields)?;
216            match record.fields.get("recordType") {
217                Some(val) if *val == "engine" => {
218                    let engine_config: Option<JSONEngineRecord> =
219                        serde_json::from_str(&stringified)?;
220                    if let Some(engine_config) = engine_config {
221                        let result =
222                            maybe_extract_engine_config(user_environment, Box::new(engine_config));
223                        engines.extend(result);
224                    }
225                }
226                Some(val) if *val == "defaultEngines" => {
227                    default_engines_record = serde_json::from_str(&stringified)?;
228                }
229                Some(val) if *val == "engineOrders" => {
230                    engine_orders_record = serde_json::from_str(&stringified)?;
231                }
232                Some(val) if *val == "availableLocales" => {
233                    // Handled above
234                }
235                // These cases are acceptable - we expect the potential for new
236                // record types/options so that we can be flexible.
237                Some(_val) => {}
238                None => {}
239            }
240        }
241
242        if let Some(overrides_data) = &overrides {
243            apply_overrides(&mut engines, overrides_data);
244        }
245
246        Ok(FilterRecordsResult {
247            engines,
248            default_engines_record,
249            engine_orders_record,
250        })
251    }
252}
253
254impl Filter for Vec<JSONSearchConfigurationRecords> {
255    fn filter_records(
256        &self,
257        user_environment: &mut SearchUserEnvironment,
258        overrides: Option<Vec<JSONOverridesRecord>>,
259    ) -> Result<FilterRecordsResult, Error> {
260        let mut available_locales = Vec::new();
261        for record in self {
262            if let JSONSearchConfigurationRecords::AvailableLocales(locales_record) = record {
263                available_locales = locales_record.locales.clone();
264            }
265        }
266        negotiate_languages(user_environment, &available_locales);
267
268        let mut engines = Vec::new();
269        let mut default_engines_record = None;
270        let mut engine_orders_record = None;
271
272        for record in self {
273            match record {
274                JSONSearchConfigurationRecords::Engine(engine) => {
275                    let result = maybe_extract_engine_config(user_environment, engine.clone());
276                    engines.extend(result);
277                }
278                JSONSearchConfigurationRecords::DefaultEngines(default_engines) => {
279                    default_engines_record = Some(default_engines);
280                }
281                JSONSearchConfigurationRecords::EngineOrders(engine_orders) => {
282                    engine_orders_record = Some(engine_orders)
283                }
284                JSONSearchConfigurationRecords::AvailableLocales(_) => {
285                    // Handled above
286                }
287                JSONSearchConfigurationRecords::Unknown => {
288                    // Prevents panics if a new record type is added in future.
289                }
290            }
291        }
292
293        if let Some(overrides_data) = &overrides {
294            apply_overrides(&mut engines, overrides_data);
295        }
296
297        Ok(FilterRecordsResult {
298            engines,
299            default_engines_record: default_engines_record.cloned(),
300            engine_orders_record: engine_orders_record.cloned(),
301        })
302    }
303}
304
305pub(crate) fn filter_engine_configuration_impl(
306    user_environment: SearchUserEnvironment,
307    configuration: &impl Filter,
308    overrides: Option<Vec<JSONOverridesRecord>>,
309) -> Result<RefinedSearchConfig, Error> {
310    let mut user_environment = user_environment.clone();
311    user_environment.locale = user_environment.locale.to_lowercase();
312    user_environment.region = user_environment.region.to_lowercase();
313    user_environment.version = user_environment.version.to_lowercase();
314
315    let filtered_result = configuration.filter_records(&mut user_environment, overrides);
316
317    filtered_result.map(|result| {
318        let (default_engine_id, default_private_engine_id) = determine_default_engines(
319            &result.engines,
320            result.default_engines_record,
321            &user_environment,
322        );
323
324        let mut engines = result.engines.clone();
325
326        if let Some(orders_record) = result.engine_orders_record {
327            for order_data in &orders_record.orders {
328                if matches_user_environment(&order_data.environment, &user_environment) {
329                    sort_helpers::set_engine_order(&mut engines, &order_data.order);
330                }
331            }
332        }
333
334        engines.sort_by(|a, b| {
335            sort_helpers::sort(
336                default_engine_id.as_ref(),
337                default_private_engine_id.as_ref(),
338                a,
339                b,
340            )
341        });
342
343        RefinedSearchConfig {
344            engines,
345            app_default_engine_id: default_engine_id,
346            app_private_default_engine_id: default_private_engine_id,
347        }
348    })
349}
350
351fn maybe_extract_engine_config(
352    user_environment: &SearchUserEnvironment,
353    record: Box<JSONEngineRecord>,
354) -> Option<SearchEngineDefinition> {
355    let JSONEngineRecord {
356        identifier,
357        variants,
358        base,
359    } = *record;
360    let matching_variant = variants
361        .into_iter()
362        .rev()
363        .find(|r| matches_user_environment(&r.environment, user_environment));
364
365    let mut matching_sub_variant = None;
366    if let Some(variant) = &matching_variant {
367        matching_sub_variant = variant
368            .sub_variants
369            .iter()
370            .rev()
371            .find(|r| matches_user_environment(&r.environment, user_environment))
372            .cloned();
373    }
374
375    matching_variant.map(|variant| {
376        SearchEngineDefinition::from_configuration_details(
377            &identifier,
378            base,
379            &variant,
380            &matching_sub_variant,
381        )
382    })
383}
384
385fn determine_default_engines(
386    engines: &[SearchEngineDefinition],
387    default_engines_record: Option<JSONDefaultEnginesRecord>,
388    user_environment: &SearchUserEnvironment,
389) -> (Option<String>, Option<String>) {
390    match default_engines_record {
391        None => (None, None),
392        Some(record) => {
393            let mut default_engine_id = None;
394            let mut default_engine_private_id = None;
395
396            let specific_default = record
397                .specific_defaults
398                .into_iter()
399                .rev()
400                .find(|r| matches_user_environment(&r.environment, user_environment));
401
402            if let Some(specific_default) = specific_default {
403                // Check the engine is present in the list of engines before
404                // we return it as default.
405                if let Some(engine_id) =
406                    find_engine_id_with_match(engines, specific_default.default)
407                {
408                    default_engine_id.replace(engine_id);
409                }
410                if let Some(private_engine_id) =
411                    find_engine_id_with_match(engines, specific_default.default_private)
412                {
413                    default_engine_private_id.replace(private_engine_id);
414                }
415            }
416
417            (
418                // If we haven't found a default engine in a specific default,
419                // then fall back to the global default engine - but only if that
420                // exists in the engine list.
421                //
422                // For the normal mode engine (`default_engine_id`), this would
423                // effectively be considered an error. However, we can't do anything
424                // sensible here, so we will return `None` to the application, and
425                // that can handle it.
426                default_engine_id.or_else(|| find_engine_id(engines, record.global_default)),
427                default_engine_private_id
428                    .or_else(|| find_engine_id(engines, record.global_default_private)),
429            )
430        }
431    }
432}
433
434fn find_engine_id(engines: &[SearchEngineDefinition], engine_id: String) -> Option<String> {
435    if engine_id.is_empty() {
436        return None;
437    }
438    match engines.iter().any(|e| e.identifier == engine_id) {
439        true => Some(engine_id.clone()),
440        false => None,
441    }
442}
443
444fn find_engine_id_with_match(
445    engines: &[SearchEngineDefinition],
446    engine_id_match: String,
447) -> Option<String> {
448    if engine_id_match.is_empty() {
449        return None;
450    }
451    if let Some(match_no_star) = engine_id_match.strip_suffix('*') {
452        return engines
453            .iter()
454            .find(|e| e.identifier.starts_with(match_no_star))
455            .map(|e| e.identifier.clone());
456    }
457
458    engines
459        .iter()
460        .find(|e| e.identifier == engine_id_match)
461        .map(|e| e.identifier.clone())
462}
463
464#[cfg(test)]
465mod tests {
466    use std::vec;
467
468    use super::*;
469    use crate::*;
470    use once_cell::sync::Lazy;
471    use pretty_assertions::assert_eq;
472
473    #[test]
474    fn test_merge_override() {
475        let mut test_engine = SearchEngineDefinition {
476            identifier: "test".to_string(),
477            partner_code: "partner-code".to_string(),
478            telemetry_suffix: "original-telemetry-suffix".to_string(),
479            ..Default::default()
480        };
481
482        let override_record = JSONOverridesRecord {
483            identifier: "test".to_string(),
484            partner_code: "override-partner-code".to_string(),
485            click_url: "https://example.com/click-url".to_string(),
486            telemetry_suffix: None,
487            urls: JSONEngineUrls {
488                search: Some(JSONEngineUrl {
489                    base: Some("https://example.com/override-search".to_string()),
490                    method: None,
491                    params: None,
492                    search_term_param_name: None,
493                }),
494                ..Default::default()
495            },
496        };
497
498        test_engine.merge_override(&override_record);
499
500        assert_eq!(
501            test_engine.partner_code, "override-partner-code",
502            "Should override the partner code"
503        );
504        assert_eq!(
505            test_engine.click_url,
506            Some("https://example.com/click-url".to_string()),
507            "Should override the click url"
508        );
509        assert_eq!(
510            test_engine.urls.search.base, "https://example.com/override-search",
511            "Should override search url"
512        );
513        assert_eq!(
514            test_engine.telemetry_suffix, "original-telemetry-suffix",
515            "Should not override telemetry suffix when telemetry suffix is supplied as None"
516        );
517    }
518
519    #[test]
520    fn test_from_configuration_details_fallsback_to_defaults() {
521        // This test doesn't use `..Default::default()` as we want to
522        // be explicit about `JSONEngineBase` and handling `None`
523        // options/default values.
524        let result = SearchEngineDefinition::from_configuration_details(
525            "test",
526            JSONEngineBase {
527                aliases: None,
528                charset: None,
529                classification: SearchEngineClassification::General,
530                name: "Test".to_string(),
531                partner_code: None,
532                urls: JSONEngineUrls {
533                    search: Some(JSONEngineUrl {
534                        base: Some("https://example.com".to_string()),
535                        method: None,
536                        params: None,
537                        search_term_param_name: None,
538                    }),
539                    suggestions: None,
540                    trending: None,
541                    search_form: None,
542                },
543            },
544            &JSONEngineVariant {
545                environment: JSONVariantEnvironment {
546                    all_regions_and_locales: true,
547                    ..Default::default()
548                },
549                is_new_until: None,
550                optional: false,
551                partner_code: None,
552                telemetry_suffix: None,
553                urls: None,
554                sub_variants: vec![],
555            },
556            &None,
557        );
558
559        assert_eq!(
560            result,
561            SearchEngineDefinition {
562                aliases: Vec::new(),
563                charset: "UTF-8".to_string(),
564                classification: SearchEngineClassification::General,
565                identifier: "test".to_string(),
566                is_new_until: None,
567                partner_code: String::new(),
568                name: "Test".to_string(),
569                optional: false,
570                order_hint: None,
571                telemetry_suffix: String::new(),
572                urls: SearchEngineUrls {
573                    search: SearchEngineUrl {
574                        base: "https://example.com".to_string(),
575                        method: "GET".to_string(),
576                        params: Vec::new(),
577                        search_term_param_name: None,
578                    },
579                    suggestions: None,
580                    trending: None,
581                    search_form: None
582                },
583                click_url: None
584            }
585        )
586    }
587
588    static ENGINE_BASE: Lazy<JSONEngineBase> = Lazy::new(|| JSONEngineBase {
589        aliases: Some(vec!["foo".to_string(), "bar".to_string()]),
590        charset: Some("ISO-8859-15".to_string()),
591        classification: SearchEngineClassification::Unknown,
592        name: "Test".to_string(),
593        partner_code: Some("firefox".to_string()),
594        urls: JSONEngineUrls {
595            search: Some(JSONEngineUrl {
596                base: Some("https://example.com".to_string()),
597                method: Some(crate::JSONEngineMethod::Post),
598                params: Some(vec![
599                    SearchUrlParam {
600                        name: "param".to_string(),
601                        value: Some("test param".to_string()),
602                        enterprise_value: None,
603                        experiment_config: None,
604                    },
605                    SearchUrlParam {
606                        name: "enterprise-name".to_string(),
607                        value: None,
608                        enterprise_value: Some("enterprise-value".to_string()),
609                        experiment_config: None,
610                    },
611                ]),
612                search_term_param_name: Some("baz".to_string()),
613            }),
614            suggestions: Some(JSONEngineUrl {
615                base: Some("https://example.com/suggestions".to_string()),
616                method: Some(crate::JSONEngineMethod::Get),
617                params: Some(vec![SearchUrlParam {
618                    name: "suggest-name".to_string(),
619                    value: None,
620                    enterprise_value: None,
621                    experiment_config: Some("suggest-experiment-value".to_string()),
622                }]),
623                search_term_param_name: Some("suggest".to_string()),
624            }),
625            trending: Some(JSONEngineUrl {
626                base: Some("https://example.com/trending".to_string()),
627                method: Some(crate::JSONEngineMethod::Get),
628                params: Some(vec![SearchUrlParam {
629                    name: "trend-name".to_string(),
630                    value: Some("trend-value".to_string()),
631                    enterprise_value: None,
632                    experiment_config: None,
633                }]),
634                search_term_param_name: None,
635            }),
636            search_form: Some(JSONEngineUrl {
637                base: Some("https://example.com/search_form".to_string()),
638                method: Some(crate::JSONEngineMethod::Get),
639                params: Some(vec![SearchUrlParam {
640                    name: "search-form-name".to_string(),
641                    value: Some("search-form-value".to_string()),
642                    enterprise_value: None,
643                    experiment_config: None,
644                }]),
645                search_term_param_name: None,
646            }),
647        },
648    });
649
650    #[test]
651    fn test_from_configuration_details_uses_values() {
652        let result = SearchEngineDefinition::from_configuration_details(
653            "test",
654            Lazy::force(&ENGINE_BASE).clone(),
655            &JSONEngineVariant {
656                environment: JSONVariantEnvironment {
657                    all_regions_and_locales: true,
658                    ..Default::default()
659                },
660                is_new_until: None,
661                optional: false,
662                partner_code: None,
663                telemetry_suffix: None,
664                urls: None,
665                sub_variants: vec![],
666            },
667            &None,
668        );
669
670        assert_eq!(
671            result,
672            SearchEngineDefinition {
673                aliases: vec!["foo".to_string(), "bar".to_string()],
674                charset: "ISO-8859-15".to_string(),
675                classification: SearchEngineClassification::Unknown,
676                identifier: "test".to_string(),
677                is_new_until: None,
678                partner_code: "firefox".to_string(),
679                name: "Test".to_string(),
680                optional: false,
681                order_hint: None,
682                telemetry_suffix: String::new(),
683                urls: SearchEngineUrls {
684                    search: SearchEngineUrl {
685                        base: "https://example.com".to_string(),
686                        method: "POST".to_string(),
687                        params: vec![
688                            SearchUrlParam {
689                                name: "param".to_string(),
690                                value: Some("test param".to_string()),
691                                enterprise_value: None,
692                                experiment_config: None,
693                            },
694                            SearchUrlParam {
695                                name: "enterprise-name".to_string(),
696                                value: None,
697                                enterprise_value: Some("enterprise-value".to_string()),
698                                experiment_config: None,
699                            },
700                        ],
701                        search_term_param_name: Some("baz".to_string()),
702                    },
703                    suggestions: Some(SearchEngineUrl {
704                        base: "https://example.com/suggestions".to_string(),
705                        method: "GET".to_string(),
706                        params: vec![SearchUrlParam {
707                            name: "suggest-name".to_string(),
708                            value: None,
709                            enterprise_value: None,
710                            experiment_config: Some("suggest-experiment-value".to_string()),
711                        }],
712                        search_term_param_name: Some("suggest".to_string()),
713                    }),
714                    trending: Some(SearchEngineUrl {
715                        base: "https://example.com/trending".to_string(),
716                        method: "GET".to_string(),
717                        params: vec![SearchUrlParam {
718                            name: "trend-name".to_string(),
719                            value: Some("trend-value".to_string()),
720                            enterprise_value: None,
721                            experiment_config: None,
722                        }],
723                        search_term_param_name: None,
724                    }),
725                    search_form: Some(SearchEngineUrl {
726                        base: "https://example.com/search_form".to_string(),
727                        method: "GET".to_string(),
728                        params: vec![SearchUrlParam {
729                            name: "search-form-name".to_string(),
730                            value: Some("search-form-value".to_string()),
731                            enterprise_value: None,
732                            experiment_config: None,
733                        }],
734                        search_term_param_name: None,
735                    }),
736                },
737                click_url: None
738            }
739        )
740    }
741
742    #[test]
743    fn test_from_configuration_details_merges_variants() {
744        let result = SearchEngineDefinition::from_configuration_details(
745            "test",
746            Lazy::force(&ENGINE_BASE).clone(),
747            &JSONEngineVariant {
748                environment: JSONVariantEnvironment {
749                    all_regions_and_locales: true,
750                    ..Default::default()
751                },
752                is_new_until: Some("2063-04-05".to_string()),
753                optional: true,
754                partner_code: Some("trek".to_string()),
755                telemetry_suffix: Some("star".to_string()),
756                urls: Some(JSONEngineUrls {
757                    search: Some(JSONEngineUrl {
758                        base: Some("https://example.com/variant".to_string()),
759                        method: Some(JSONEngineMethod::Get),
760                        params: Some(vec![SearchUrlParam {
761                            name: "variant".to_string(),
762                            value: Some("test variant".to_string()),
763                            enterprise_value: None,
764                            experiment_config: None,
765                        }]),
766                        search_term_param_name: Some("ship".to_string()),
767                    }),
768                    suggestions: Some(JSONEngineUrl {
769                        base: Some("https://example.com/suggestions-variant".to_string()),
770                        method: Some(JSONEngineMethod::Get),
771                        params: Some(vec![SearchUrlParam {
772                            name: "suggest-variant".to_string(),
773                            value: Some("sugg test variant".to_string()),
774                            enterprise_value: None,
775                            experiment_config: None,
776                        }]),
777                        search_term_param_name: Some("variant".to_string()),
778                    }),
779                    trending: Some(JSONEngineUrl {
780                        base: Some("https://example.com/trending-variant".to_string()),
781                        method: Some(JSONEngineMethod::Get),
782                        params: Some(vec![SearchUrlParam {
783                            name: "trend-variant".to_string(),
784                            value: Some("trend test variant".to_string()),
785                            enterprise_value: None,
786                            experiment_config: None,
787                        }]),
788                        search_term_param_name: Some("trend".to_string()),
789                    }),
790                    search_form: Some(JSONEngineUrl {
791                        base: Some("https://example.com/search_form".to_string()),
792                        method: Some(crate::JSONEngineMethod::Get),
793                        params: Some(vec![SearchUrlParam {
794                            name: "search-form-name".to_string(),
795                            value: Some("search-form-value".to_string()),
796                            enterprise_value: None,
797                            experiment_config: None,
798                        }]),
799                        search_term_param_name: None,
800                    }),
801                }),
802                sub_variants: vec![],
803            },
804            &None,
805        );
806
807        assert_eq!(
808            result,
809            SearchEngineDefinition {
810                aliases: vec!["foo".to_string(), "bar".to_string()],
811                charset: "ISO-8859-15".to_string(),
812                classification: SearchEngineClassification::Unknown,
813                identifier: "test".to_string(),
814                is_new_until: Some("2063-04-05".to_string()),
815                partner_code: "trek".to_string(),
816                name: "Test".to_string(),
817                optional: true,
818                order_hint: None,
819                telemetry_suffix: "star".to_string(),
820                urls: SearchEngineUrls {
821                    search: SearchEngineUrl {
822                        base: "https://example.com/variant".to_string(),
823                        method: "GET".to_string(),
824                        params: vec![SearchUrlParam {
825                            name: "variant".to_string(),
826                            value: Some("test variant".to_string()),
827                            enterprise_value: None,
828                            experiment_config: None,
829                        }],
830                        search_term_param_name: Some("ship".to_string()),
831                    },
832                    suggestions: Some(SearchEngineUrl {
833                        base: "https://example.com/suggestions-variant".to_string(),
834                        method: "GET".to_string(),
835                        params: vec![SearchUrlParam {
836                            name: "suggest-variant".to_string(),
837                            value: Some("sugg test variant".to_string()),
838                            enterprise_value: None,
839                            experiment_config: None,
840                        }],
841                        search_term_param_name: Some("variant".to_string()),
842                    }),
843                    trending: Some(SearchEngineUrl {
844                        base: "https://example.com/trending-variant".to_string(),
845                        method: "GET".to_string(),
846                        params: vec![SearchUrlParam {
847                            name: "trend-variant".to_string(),
848                            value: Some("trend test variant".to_string()),
849                            enterprise_value: None,
850                            experiment_config: None,
851                        }],
852                        search_term_param_name: Some("trend".to_string()),
853                    }),
854                    search_form: Some(SearchEngineUrl {
855                        base: "https://example.com/search_form".to_string(),
856                        method: "GET".to_string(),
857                        params: vec![SearchUrlParam {
858                            name: "search-form-name".to_string(),
859                            value: Some("search-form-value".to_string()),
860                            enterprise_value: None,
861                            experiment_config: None,
862                        }],
863                        search_term_param_name: None,
864                    }),
865                },
866                click_url: None
867            }
868        )
869    }
870
871    #[test]
872    fn test_from_configuration_details_merges_sub_variants() {
873        let result = SearchEngineDefinition::from_configuration_details(
874            "test",
875            Lazy::force(&ENGINE_BASE).clone(),
876            &JSONEngineVariant {
877                environment: JSONVariantEnvironment {
878                    all_regions_and_locales: true,
879                    ..Default::default()
880                },
881                is_new_until: None,
882                optional: true,
883                partner_code: Some("trek".to_string()),
884                telemetry_suffix: Some("star".to_string()),
885                urls: Some(JSONEngineUrls {
886                    search: Some(JSONEngineUrl {
887                        base: Some("https://example.com/variant".to_string()),
888                        method: Some(JSONEngineMethod::Get),
889                        params: Some(vec![SearchUrlParam {
890                            name: "variant".to_string(),
891                            value: Some("test variant".to_string()),
892                            enterprise_value: None,
893                            experiment_config: None,
894                        }]),
895                        search_term_param_name: Some("ship".to_string()),
896                    }),
897                    suggestions: Some(JSONEngineUrl {
898                        base: Some("https://example.com/suggestions-variant".to_string()),
899                        method: Some(JSONEngineMethod::Get),
900                        params: Some(vec![SearchUrlParam {
901                            name: "suggest-variant".to_string(),
902                            value: Some("sugg test variant".to_string()),
903                            enterprise_value: None,
904                            experiment_config: None,
905                        }]),
906                        search_term_param_name: Some("variant".to_string()),
907                    }),
908                    trending: Some(JSONEngineUrl {
909                        base: Some("https://example.com/trending-variant".to_string()),
910                        method: Some(JSONEngineMethod::Get),
911                        params: Some(vec![SearchUrlParam {
912                            name: "trend-variant".to_string(),
913                            value: Some("trend test variant".to_string()),
914                            enterprise_value: None,
915                            experiment_config: None,
916                        }]),
917                        search_term_param_name: Some("trend".to_string()),
918                    }),
919                    search_form: Some(JSONEngineUrl {
920                        base: Some("https://example.com/search-form-variant".to_string()),
921                        method: Some(crate::JSONEngineMethod::Get),
922                        params: Some(vec![SearchUrlParam {
923                            name: "search-form-variant".to_string(),
924                            value: Some("search form variant".to_string()),
925                            enterprise_value: None,
926                            experiment_config: None,
927                        }]),
928                        search_term_param_name: None,
929                    }),
930                }),
931                // This would be the list of sub-variants for this part of the
932                // configuration, however it is not used as the actual sub-variant
933                // to be merged is passed as the third argument to
934                // `from_configuration_details`.
935                sub_variants: vec![],
936            },
937            &Some(JSONEngineVariant {
938                environment: JSONVariantEnvironment {
939                    all_regions_and_locales: true,
940                    ..Default::default()
941                },
942                is_new_until: Some("2063-04-05".to_string()),
943                optional: true,
944                partner_code: Some("trek2".to_string()),
945                telemetry_suffix: Some("star2".to_string()),
946                urls: Some(JSONEngineUrls {
947                    search: Some(JSONEngineUrl {
948                        base: Some("https://example.com/subvariant".to_string()),
949                        method: Some(JSONEngineMethod::Get),
950                        params: Some(vec![SearchUrlParam {
951                            name: "subvariant".to_string(),
952                            value: Some("test subvariant".to_string()),
953                            enterprise_value: None,
954                            experiment_config: None,
955                        }]),
956                        search_term_param_name: Some("shuttle".to_string()),
957                    }),
958                    suggestions: Some(JSONEngineUrl {
959                        base: Some("https://example.com/suggestions-subvariant".to_string()),
960                        method: Some(JSONEngineMethod::Get),
961                        params: Some(vec![SearchUrlParam {
962                            name: "suggest-subvariant".to_string(),
963                            value: Some("sugg test subvariant".to_string()),
964                            enterprise_value: None,
965                            experiment_config: None,
966                        }]),
967                        search_term_param_name: Some("subvariant".to_string()),
968                    }),
969                    trending: Some(JSONEngineUrl {
970                        base: Some("https://example.com/trending-subvariant".to_string()),
971                        method: Some(JSONEngineMethod::Get),
972                        params: Some(vec![SearchUrlParam {
973                            name: "trend-subvariant".to_string(),
974                            value: Some("trend test subvariant".to_string()),
975                            enterprise_value: None,
976                            experiment_config: None,
977                        }]),
978                        search_term_param_name: Some("subtrend".to_string()),
979                    }),
980                    search_form: Some(JSONEngineUrl {
981                        base: Some("https://example.com/search-form-subvariant".to_string()),
982                        method: Some(crate::JSONEngineMethod::Get),
983                        params: Some(vec![SearchUrlParam {
984                            name: "search-form-subvariant".to_string(),
985                            value: Some("search form subvariant".to_string()),
986                            enterprise_value: None,
987                            experiment_config: None,
988                        }]),
989                        search_term_param_name: None,
990                    }),
991                }),
992                sub_variants: vec![],
993            }),
994        );
995
996        assert_eq!(
997            result,
998            SearchEngineDefinition {
999                aliases: vec!["foo".to_string(), "bar".to_string()],
1000                charset: "ISO-8859-15".to_string(),
1001                classification: SearchEngineClassification::Unknown,
1002                identifier: "test".to_string(),
1003                is_new_until: Some("2063-04-05".to_string()),
1004                partner_code: "trek2".to_string(),
1005                name: "Test".to_string(),
1006                optional: true,
1007                order_hint: None,
1008                telemetry_suffix: "star2".to_string(),
1009                urls: SearchEngineUrls {
1010                    search: SearchEngineUrl {
1011                        base: "https://example.com/subvariant".to_string(),
1012                        method: "GET".to_string(),
1013                        params: vec![SearchUrlParam {
1014                            name: "subvariant".to_string(),
1015                            value: Some("test subvariant".to_string()),
1016                            enterprise_value: None,
1017                            experiment_config: None,
1018                        }],
1019                        search_term_param_name: Some("shuttle".to_string()),
1020                    },
1021                    suggestions: Some(SearchEngineUrl {
1022                        base: "https://example.com/suggestions-subvariant".to_string(),
1023                        method: "GET".to_string(),
1024                        params: vec![SearchUrlParam {
1025                            name: "suggest-subvariant".to_string(),
1026                            value: Some("sugg test subvariant".to_string()),
1027                            enterprise_value: None,
1028                            experiment_config: None,
1029                        }],
1030                        search_term_param_name: Some("subvariant".to_string()),
1031                    }),
1032                    trending: Some(SearchEngineUrl {
1033                        base: "https://example.com/trending-subvariant".to_string(),
1034                        method: "GET".to_string(),
1035                        params: vec![SearchUrlParam {
1036                            name: "trend-subvariant".to_string(),
1037                            value: Some("trend test subvariant".to_string()),
1038                            enterprise_value: None,
1039                            experiment_config: None,
1040                        }],
1041                        search_term_param_name: Some("subtrend".to_string()),
1042                    }),
1043                    search_form: Some(SearchEngineUrl {
1044                        base: "https://example.com/search-form-subvariant".to_string(),
1045                        method: "GET".to_string(),
1046                        params: vec![SearchUrlParam {
1047                            name: "search-form-subvariant".to_string(),
1048                            value: Some("search form subvariant".to_string()),
1049                            enterprise_value: None,
1050                            experiment_config: None,
1051                        }],
1052                        search_term_param_name: None,
1053                    }),
1054                },
1055                click_url: None
1056            }
1057        )
1058    }
1059
1060    static ENGINES_LIST: Lazy<Vec<SearchEngineDefinition>> = Lazy::new(|| {
1061        vec![
1062            SearchEngineDefinition {
1063                identifier: "engine1".to_string(),
1064                name: "Test".to_string(),
1065                urls: SearchEngineUrls {
1066                    search: SearchEngineUrl {
1067                        base: "https://example.com".to_string(),
1068                        method: "GET".to_string(),
1069                        params: Vec::new(),
1070                        search_term_param_name: None,
1071                    },
1072                    ..Default::default()
1073                },
1074                ..Default::default()
1075            },
1076            SearchEngineDefinition {
1077                identifier: "engine2".to_string(),
1078                name: "Test 2".to_string(),
1079                urls: SearchEngineUrls {
1080                    search: SearchEngineUrl {
1081                        base: "https://example.com/2".to_string(),
1082                        method: "GET".to_string(),
1083                        params: Vec::new(),
1084                        search_term_param_name: None,
1085                    },
1086                    ..Default::default()
1087                },
1088                ..Default::default()
1089            },
1090            SearchEngineDefinition {
1091                identifier: "engine3".to_string(),
1092                name: "Test 3".to_string(),
1093                urls: SearchEngineUrls {
1094                    search: SearchEngineUrl {
1095                        base: "https://example.com/3".to_string(),
1096                        method: "GET".to_string(),
1097                        params: Vec::new(),
1098                        search_term_param_name: None,
1099                    },
1100                    ..Default::default()
1101                },
1102                ..Default::default()
1103            },
1104            SearchEngineDefinition {
1105                identifier: "engine4wildcardmatch".to_string(),
1106                name: "Test 4".to_string(),
1107                urls: SearchEngineUrls {
1108                    search: SearchEngineUrl {
1109                        base: "https://example.com/4".to_string(),
1110                        method: "GET".to_string(),
1111                        params: Vec::new(),
1112                        search_term_param_name: None,
1113                    },
1114                    ..Default::default()
1115                },
1116                ..Default::default()
1117            },
1118        ]
1119    });
1120
1121    #[test]
1122    fn test_determine_default_engines_returns_global_default() {
1123        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1124            &ENGINES_LIST,
1125            Some(JSONDefaultEnginesRecord {
1126                global_default: "engine2".to_string(),
1127                global_default_private: String::new(),
1128                specific_defaults: Vec::new(),
1129            }),
1130            &SearchUserEnvironment {
1131                locale: "fi".into(),
1132                ..Default::default()
1133            },
1134        );
1135
1136        assert_eq!(
1137            default_engine_id.unwrap(),
1138            "engine2",
1139            "Should have returned the global default engine"
1140        );
1141        assert!(
1142            default_engine_private_id.is_none(),
1143            "Should not have returned an id for the private engine"
1144        );
1145
1146        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1147            &ENGINES_LIST,
1148            Some(JSONDefaultEnginesRecord {
1149                global_default: "engine2".to_string(),
1150                global_default_private: String::new(),
1151                specific_defaults: vec![JSONSpecificDefaultRecord {
1152                    default: "engine1".to_string(),
1153                    default_private: String::new(),
1154                    environment: JSONVariantEnvironment {
1155                        locales: vec!["en-GB".to_string()],
1156                        ..Default::default()
1157                    },
1158                }],
1159            }),
1160            &SearchUserEnvironment {
1161                locale: "fi".into(),
1162                ..Default::default()
1163            },
1164        );
1165
1166        assert_eq!(
1167            default_engine_id.unwrap(),
1168            "engine2",
1169            "Should have returned the global default engine when no specific defaults environments match"
1170        );
1171        assert!(
1172            default_engine_private_id.is_none(),
1173            "Should not have returned an id for the private engine"
1174        );
1175
1176        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1177            &ENGINES_LIST,
1178            Some(JSONDefaultEnginesRecord {
1179                global_default: "engine2".to_string(),
1180                global_default_private: String::new(),
1181                specific_defaults: vec![JSONSpecificDefaultRecord {
1182                    default: "engine1".to_string(),
1183                    default_private: String::new(),
1184                    environment: JSONVariantEnvironment {
1185                        locales: vec!["fi".to_string()],
1186                        ..Default::default()
1187                    },
1188                }],
1189            }),
1190            &SearchUserEnvironment {
1191                locale: "fi".into(),
1192                ..Default::default()
1193            },
1194        );
1195
1196        assert_eq!(
1197            default_engine_id.unwrap(),
1198            "engine1",
1199            "Should have returned the specific default when environments match"
1200        );
1201        assert!(
1202            default_engine_private_id.is_none(),
1203            "Should not have returned an id for the private engine"
1204        );
1205
1206        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1207            &ENGINES_LIST,
1208            Some(JSONDefaultEnginesRecord {
1209                global_default: "engine2".to_string(),
1210                global_default_private: String::new(),
1211                specific_defaults: vec![JSONSpecificDefaultRecord {
1212                    default: "engine4*".to_string(),
1213                    default_private: String::new(),
1214                    environment: JSONVariantEnvironment {
1215                        locales: vec!["fi".to_string()],
1216                        ..Default::default()
1217                    },
1218                }],
1219            }),
1220            &SearchUserEnvironment {
1221                locale: "fi".into(),
1222                ..Default::default()
1223            },
1224        );
1225
1226        assert_eq!(
1227            default_engine_id.unwrap(),
1228            "engine4wildcardmatch",
1229            "Should have returned the specific default when using a wildcard match"
1230        );
1231        assert!(
1232            default_engine_private_id.is_none(),
1233            "Should not have returned an id for the private engine"
1234        );
1235
1236        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1237            &ENGINES_LIST,
1238            Some(JSONDefaultEnginesRecord {
1239                global_default: "engine2".to_string(),
1240                global_default_private: String::new(),
1241                specific_defaults: vec![
1242                    JSONSpecificDefaultRecord {
1243                        default: "engine4*".to_string(),
1244                        default_private: String::new(),
1245                        environment: JSONVariantEnvironment {
1246                            locales: vec!["fi".to_string()],
1247                            ..Default::default()
1248                        },
1249                    },
1250                    JSONSpecificDefaultRecord {
1251                        default: "engine3".to_string(),
1252                        default_private: String::new(),
1253                        environment: JSONVariantEnvironment {
1254                            locales: vec!["fi".to_string()],
1255                            ..Default::default()
1256                        },
1257                    },
1258                ],
1259            }),
1260            &SearchUserEnvironment {
1261                locale: "fi".into(),
1262                ..Default::default()
1263            },
1264        );
1265
1266        assert_eq!(
1267            default_engine_id.unwrap(),
1268            "engine3",
1269            "Should have returned the last specific default when multiple environments match"
1270        );
1271        assert!(
1272            default_engine_private_id.is_none(),
1273            "Should not have returned an id for the private engine"
1274        );
1275    }
1276
1277    #[test]
1278    fn test_determine_default_engines_returns_global_default_private() {
1279        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1280            &ENGINES_LIST,
1281            Some(JSONDefaultEnginesRecord {
1282                global_default: "engine2".to_string(),
1283                global_default_private: "engine3".to_string(),
1284                specific_defaults: Vec::new(),
1285            }),
1286            &SearchUserEnvironment {
1287                ..Default::default()
1288            },
1289        );
1290
1291        assert_eq!(
1292            default_engine_id.unwrap(),
1293            "engine2",
1294            "Should have returned the global default engine"
1295        );
1296        assert_eq!(
1297            default_engine_private_id.unwrap(),
1298            "engine3",
1299            "Should have returned the global default engine for private mode"
1300        );
1301
1302        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1303            &ENGINES_LIST,
1304            Some(JSONDefaultEnginesRecord {
1305                global_default: "engine2".to_string(),
1306                global_default_private: "engine3".to_string(),
1307                specific_defaults: vec![JSONSpecificDefaultRecord {
1308                    default: String::new(),
1309                    default_private: "engine1".to_string(),
1310                    environment: JSONVariantEnvironment {
1311                        locales: vec!["en-GB".to_string()],
1312                        ..Default::default()
1313                    },
1314                }],
1315            }),
1316            &SearchUserEnvironment {
1317                locale: "fi".into(),
1318                ..Default::default()
1319            },
1320        );
1321
1322        assert_eq!(
1323            default_engine_id.unwrap(),
1324            "engine2",
1325            "Should have returned the global default engine when no specific defaults environments match"
1326        );
1327        assert_eq!(
1328            default_engine_private_id.unwrap(),
1329            "engine3",
1330            "Should have returned the global default engine for private mode when no specific defaults environments match"
1331        );
1332
1333        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1334            &ENGINES_LIST,
1335            Some(JSONDefaultEnginesRecord {
1336                global_default: "engine2".to_string(),
1337                global_default_private: "engine3".to_string(),
1338                specific_defaults: vec![JSONSpecificDefaultRecord {
1339                    default: String::new(),
1340                    default_private: "engine1".to_string(),
1341                    environment: JSONVariantEnvironment {
1342                        locales: vec!["fi".to_string()],
1343                        ..Default::default()
1344                    },
1345                }],
1346            }),
1347            &SearchUserEnvironment {
1348                locale: "fi".into(),
1349                ..Default::default()
1350            },
1351        );
1352
1353        assert_eq!(
1354            default_engine_id.unwrap(),
1355            "engine2",
1356            "Should have returned the global default engine when specific environments match which override the private global default (and not the global default)."
1357        );
1358        assert_eq!(
1359            default_engine_private_id.unwrap(),
1360            "engine1",
1361            "Should have returned the specific default engine for private mode when environments match"
1362        );
1363
1364        let (default_engine_id, default_engine_private_id) = determine_default_engines(
1365            &ENGINES_LIST,
1366            Some(JSONDefaultEnginesRecord {
1367                global_default: "engine2".to_string(),
1368                global_default_private: String::new(),
1369                specific_defaults: vec![JSONSpecificDefaultRecord {
1370                    default: String::new(),
1371                    default_private: "engine4*".to_string(),
1372                    environment: JSONVariantEnvironment {
1373                        locales: vec!["fi".to_string()],
1374                        ..Default::default()
1375                    },
1376                }],
1377            }),
1378            &SearchUserEnvironment {
1379                locale: "fi".into(),
1380                ..Default::default()
1381            },
1382        );
1383
1384        assert_eq!(
1385            default_engine_id.unwrap(),
1386            "engine2",
1387            "Should have returned the global default engine when specific environments match which override the private global default (and not the global default)"
1388        );
1389        assert_eq!(
1390            default_engine_private_id.unwrap(),
1391            "engine4wildcardmatch",
1392            "Should have returned the specific default for private mode when using a wildcard match"
1393        );
1394    }
1395
1396    #[test]
1397    fn test_locale_matched_exactly() {
1398        let mut user_env = SearchUserEnvironment {
1399            locale: "en-CA".into(),
1400            ..Default::default()
1401        };
1402        negotiate_languages(&mut user_env, &["en-CA".to_string(), "fr".to_string()]);
1403        assert_eq!(
1404            user_env.locale, "en-CA",
1405            "Should return user locale unchanged if in available locales"
1406        );
1407    }
1408
1409    #[test]
1410    fn test_locale_fallback_to_base_locale() {
1411        let mut user_env = SearchUserEnvironment {
1412            locale: "de-AT".into(),
1413            ..Default::default()
1414        };
1415        negotiate_languages(&mut user_env, &["de".to_string()]);
1416        assert_eq!(
1417            user_env.locale, "de",
1418            "Should fallback to base locale if base is in available locales"
1419        );
1420    }
1421
1422    static ENGLISH_LOCALES: &[&str] = &["en-AU", "en-IE", "en-RU", "en-ZA"];
1423
1424    #[test]
1425    fn test_english_locales_fallbacks_to_en_us() {
1426        for user_locale in ENGLISH_LOCALES {
1427            let mut user_env = SearchUserEnvironment {
1428                locale: user_locale.to_string(),
1429                ..Default::default()
1430            };
1431            negotiate_languages(&mut user_env, &["en-US".to_string()]);
1432            assert_eq!(
1433                user_env.locale, "en-us",
1434                "Should remap {} to en-us when en-us is available",
1435                user_locale
1436            );
1437        }
1438    }
1439
1440    #[test]
1441    fn test_locale_unmatched() {
1442        let mut user_env = SearchUserEnvironment {
1443            locale: "fr-CA".into(),
1444            ..Default::default()
1445        };
1446        negotiate_languages(&mut user_env, &["de".to_string(), "en-US".to_string()]);
1447        assert_eq!(
1448            user_env.locale, "fr-CA",
1449            "Should leave locale unchanged if no match or english locale fallback is not found"
1450        );
1451    }
1452}