suggest/
rs.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
6//! Crate-internal types for interacting with Remote Settings (`rs`). Types in
7//! this module describe records and attachments in the Suggest Remote Settings
8//! collection.
9//!
10//! To add a new suggestion `T` to this component, you'll generally need to:
11//!
12//!  1. Add a variant named `T` to [`SuggestRecord`]. The variant must have a
13//!     `#[serde(rename)]` attribute that matches the suggestion record's
14//!     `type` field.
15//!  2. Define a `DownloadedTSuggestion` type with the new suggestion's fields,
16//!     matching their attachment's schema. Your new type must derive or
17//!     implement [`serde::Deserialize`].
18//!  3. Update the database schema in the [`schema`] module to store the new
19//!     suggestion.
20//!  4. Add an `insert_t_suggestions()` method to [`db::SuggestDao`] that
21//!     inserts `DownloadedTSuggestion`s into the database.
22//!  5. Update [`store::SuggestStoreInner::ingest()`] to download, deserialize,
23//!     and store the new suggestion.
24//!  6. Add a variant named `T` to [`suggestion::Suggestion`], with the fields
25//!     that you'd like to expose to the application. These can be the same
26//!     fields as `DownloadedTSuggestion`, or slightly different, depending on
27//!     what the application needs to show the suggestion.
28//!  7. Update the `Suggestion` enum definition in `suggest.udl` to match your
29//!     new [`suggestion::Suggestion`] variant.
30//!  8. Update any [`db::SuggestDao`] methods that query the database to include
31//!     the new suggestion in their results, and return `Suggestion::T` variants
32//!     as needed.
33
34use std::{fmt, sync::Arc};
35
36use remote_settings::{
37    Attachment, RemoteSettingsClient, RemoteSettingsError, RemoteSettingsRecord,
38    RemoteSettingsService,
39};
40use serde::{Deserialize, Serialize};
41use serde_json::{Map, Value};
42
43use crate::{error::Error, query::full_keywords_to_fts_content, Result};
44use rusqlite::{types::ToSqlOutput, ToSql};
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum Collection {
48    Amp,
49    Fakespot,
50    Other,
51}
52
53impl Collection {
54    pub fn name(&self) -> &'static str {
55        match self {
56            Self::Amp => "quicksuggest-amp",
57            Self::Fakespot => "fakespot-suggest-products",
58            Self::Other => "quicksuggest-other",
59        }
60    }
61}
62
63/// A trait for a client that downloads suggestions from Remote Settings.
64///
65/// This trait lets tests use a mock client.
66pub(crate) trait Client {
67    /// Get all records from the server
68    ///
69    /// We use this plus client-side filtering rather than any server-side filtering, as
70    /// recommended by the remote settings docs
71    /// (https://remote-settings.readthedocs.io/en/stable/client-specifications.html). This is
72    /// relatively inexpensive since we use a cache and don't fetch attachments until after the
73    /// client-side filtering.
74    ///
75    /// Records that can't be parsed as [SuggestRecord] are ignored.
76    fn get_records(&self, collection: Collection) -> Result<Vec<Record>>;
77
78    fn download_attachment(&self, record: &Record) -> Result<Vec<u8>>;
79}
80
81/// Implements the [Client] trait using a real remote settings client
82pub struct SuggestRemoteSettingsClient {
83    // Create a separate client for each collection name
84    amp_client: Arc<RemoteSettingsClient>,
85    other_client: Arc<RemoteSettingsClient>,
86    fakespot_client: Arc<RemoteSettingsClient>,
87}
88
89impl SuggestRemoteSettingsClient {
90    pub fn new(rs_service: &RemoteSettingsService) -> Self {
91        Self {
92            amp_client: rs_service.make_client(Collection::Amp.name().to_owned()),
93            other_client: rs_service.make_client(Collection::Other.name().to_owned()),
94            fakespot_client: rs_service.make_client(Collection::Fakespot.name().to_owned()),
95        }
96    }
97
98    fn client_for_collection(&self, collection: Collection) -> &RemoteSettingsClient {
99        match collection {
100            Collection::Amp => &self.amp_client,
101            Collection::Other => &self.other_client,
102            Collection::Fakespot => &self.fakespot_client,
103        }
104    }
105}
106
107impl Client for SuggestRemoteSettingsClient {
108    fn get_records(&self, collection: Collection) -> Result<Vec<Record>> {
109        let client = self.client_for_collection(collection);
110        client.sync()?;
111        let response = client.get_records(false);
112        match response {
113            Some(r) => Ok(r
114                .into_iter()
115                .filter_map(|r| Record::new(r, collection).ok())
116                .collect()),
117            None => Err(Error::RemoteSettings(RemoteSettingsError::Other {
118                reason: "Unable to get records".to_owned(),
119            })),
120        }
121    }
122
123    fn download_attachment(&self, record: &Record) -> Result<Vec<u8>> {
124        let converted_record: RemoteSettingsRecord = record.clone().into();
125        match &record.attachment {
126            Some(_) => Ok(self
127                .client_for_collection(record.collection)
128                .get_attachment(&converted_record)?),
129            None => Err(Error::MissingAttachment(record.id.to_string())),
130        }
131    }
132}
133
134/// Remote settings record for suggest.
135///
136/// This is a `remote_settings::RemoteSettingsRecord` parsed for suggest.
137#[derive(Clone, Debug)]
138pub(crate) struct Record {
139    pub id: SuggestRecordId,
140    pub last_modified: u64,
141    pub attachment: Option<Attachment>,
142    pub payload: SuggestRecord,
143    pub collection: Collection,
144}
145
146impl Record {
147    pub fn new(record: RemoteSettingsRecord, collection: Collection) -> Result<Self> {
148        Ok(Self {
149            id: SuggestRecordId::new(record.id),
150            last_modified: record.last_modified,
151            attachment: record.attachment,
152            payload: serde_json::from_value(serde_json::Value::Object(record.fields))?,
153            collection,
154        })
155    }
156
157    pub fn record_type(&self) -> SuggestRecordType {
158        (&self.payload).into()
159    }
160}
161
162impl From<Record> for RemoteSettingsRecord {
163    fn from(record: Record) -> Self {
164        RemoteSettingsRecord {
165            id: record.id.to_string(),
166            last_modified: record.last_modified,
167            deleted: false,
168            attachment: record.attachment.clone(),
169            fields: record.payload.to_json_map(),
170        }
171    }
172}
173
174/// A record in the Suggest Remote Settings collection.
175///
176/// Most Suggest records don't carry inline fields except for `type`.
177/// Suggestions themselves are typically stored in each record's attachment.
178#[derive(Clone, Debug, Deserialize, Serialize)]
179#[serde(tag = "type")]
180pub(crate) enum SuggestRecord {
181    #[serde(rename = "icon")]
182    Icon,
183    #[serde(rename = "amp")]
184    Amp,
185    #[serde(rename = "wikipedia")]
186    Wikipedia,
187    #[serde(rename = "amo-suggestions")]
188    Amo,
189    #[serde(rename = "yelp-suggestions")]
190    Yelp,
191    #[serde(rename = "mdn-suggestions")]
192    Mdn,
193    #[serde(rename = "weather")]
194    Weather,
195    #[serde(rename = "configuration")]
196    GlobalConfig(DownloadedGlobalConfig),
197    #[serde(rename = "fakespot-suggestions")]
198    Fakespot,
199    #[serde(rename = "dynamic-suggestions")]
200    Dynamic(DownloadedDynamicRecord),
201    #[serde(rename = "geonames-2")] // version 2
202    Geonames,
203    #[serde(rename = "geonames-alternates")]
204    GeonamesAlternates,
205}
206
207impl SuggestRecord {
208    fn to_json_map(&self) -> Map<String, Value> {
209        match serde_json::to_value(self) {
210            Ok(Value::Object(map)) => map,
211            _ => unreachable!(),
212        }
213    }
214}
215
216/// Enum for the different record types that can be consumed.
217/// Extracting this from the serialization enum so that we can
218/// extend it to get type metadata.
219#[derive(Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
220pub enum SuggestRecordType {
221    Icon,
222    Amp,
223    Wikipedia,
224    Amo,
225    Yelp,
226    Mdn,
227    Weather,
228    GlobalConfig,
229    Fakespot,
230    Dynamic,
231    Geonames,
232    GeonamesAlternates,
233}
234
235impl From<&SuggestRecord> for SuggestRecordType {
236    fn from(suggest_record: &SuggestRecord) -> Self {
237        match suggest_record {
238            SuggestRecord::Amo => Self::Amo,
239            SuggestRecord::Amp => Self::Amp,
240            SuggestRecord::Wikipedia => Self::Wikipedia,
241            SuggestRecord::Icon => Self::Icon,
242            SuggestRecord::Mdn => Self::Mdn,
243            SuggestRecord::Weather => Self::Weather,
244            SuggestRecord::Yelp => Self::Yelp,
245            SuggestRecord::GlobalConfig(_) => Self::GlobalConfig,
246            SuggestRecord::Fakespot => Self::Fakespot,
247            SuggestRecord::Dynamic(_) => Self::Dynamic,
248            SuggestRecord::Geonames => Self::Geonames,
249            SuggestRecord::GeonamesAlternates => Self::GeonamesAlternates,
250        }
251    }
252}
253
254impl fmt::Display for SuggestRecordType {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        write!(f, "{}", self.as_str())
257    }
258}
259
260impl ToSql for SuggestRecordType {
261    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
262        Ok(ToSqlOutput::from(self.as_str()))
263    }
264}
265
266impl SuggestRecordType {
267    /// Get all record types to iterate over
268    ///
269    /// Currently only used by tests
270    #[cfg(test)]
271    pub fn all() -> &'static [SuggestRecordType] {
272        &[
273            Self::Icon,
274            Self::Amp,
275            Self::Wikipedia,
276            Self::Amo,
277            Self::Yelp,
278            Self::Mdn,
279            Self::Weather,
280            Self::GlobalConfig,
281            Self::Fakespot,
282            Self::Dynamic,
283            Self::Geonames,
284            Self::GeonamesAlternates,
285        ]
286    }
287
288    pub fn as_str(&self) -> &str {
289        match self {
290            Self::Icon => "icon",
291            Self::Amp => "amp",
292            Self::Wikipedia => "wikipedia",
293            Self::Amo => "amo-suggestions",
294            Self::Yelp => "yelp-suggestions",
295            Self::Mdn => "mdn-suggestions",
296            Self::Weather => "weather",
297            Self::GlobalConfig => "configuration",
298            Self::Fakespot => "fakespot-suggestions",
299            Self::Dynamic => "dynamic-suggestions",
300            Self::Geonames => "geonames-2",
301            Self::GeonamesAlternates => "geonames-alternates",
302        }
303    }
304}
305
306/// Represents either a single value, or a list of values. This is used to
307/// deserialize downloaded attachments.
308#[derive(Clone, Debug, Deserialize)]
309#[serde(untagged)]
310enum OneOrMany<T> {
311    One(T),
312    Many(Vec<T>),
313}
314
315/// A downloaded Remote Settings attachment that contains suggestions.
316#[derive(Clone, Debug, Deserialize)]
317#[serde(transparent)]
318pub(crate) struct SuggestAttachment<T>(OneOrMany<T>);
319
320impl<T> SuggestAttachment<T> {
321    /// Returns a slice of suggestions to ingest from the downloaded attachment.
322    pub fn suggestions(&self) -> &[T] {
323        match &self.0 {
324            OneOrMany::One(value) => std::slice::from_ref(value),
325            OneOrMany::Many(values) => values,
326        }
327    }
328}
329
330/// The ID of a record in the Suggest Remote Settings collection.
331#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
332#[serde(transparent)]
333pub(crate) struct SuggestRecordId(String);
334
335impl SuggestRecordId {
336    pub fn new(id: String) -> Self {
337        Self(id)
338    }
339
340    pub fn as_str(&self) -> &str {
341        &self.0
342    }
343
344    /// If this ID is for an icon record, extracts and returns the icon ID.
345    ///
346    /// The icon ID is the primary key for an ingested icon. Downloaded
347    /// suggestions also reference these icon IDs, in
348    /// [`DownloadedSuggestion::icon_id`].
349    pub fn as_icon_id(&self) -> Option<&str> {
350        self.0.strip_prefix("icon-")
351    }
352}
353
354impl fmt::Display for SuggestRecordId {
355    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356        write!(f, "{}", self.0)
357    }
358}
359
360/// An AMP suggestion to ingest from an AMP attachment.
361#[derive(Clone, Debug, Default, Deserialize)]
362pub(crate) struct DownloadedAmpSuggestion {
363    pub keywords: Vec<String>,
364    pub title: String,
365    pub url: String,
366    pub score: Option<f64>,
367    #[serde(default)]
368    pub full_keywords: Vec<(String, usize)>,
369    pub advertiser: String,
370    #[serde(rename = "id")]
371    pub block_id: i32,
372    pub iab_category: String,
373    pub serp_categories: Option<Vec<i32>>,
374    pub click_url: String,
375    pub impression_url: String,
376    #[serde(rename = "icon")]
377    pub icon_id: String,
378}
379
380/// A Wikipedia suggestion to ingest from a Wikipedia attachment.
381#[derive(Clone, Debug, Default, Deserialize)]
382pub(crate) struct DownloadedWikipediaSuggestion {
383    pub keywords: Vec<String>,
384    pub title: String,
385    pub url: String,
386    pub score: Option<f64>,
387    #[serde(default)]
388    pub full_keywords: Vec<(String, usize)>,
389    #[serde(rename = "icon")]
390    pub icon_id: String,
391}
392
393/// Iterate over all AMP/Wikipedia-style keywords.
394pub fn iterate_keywords<'a>(
395    keywords: &'a [String],
396    full_keywords: &'a [(String, usize)],
397) -> impl Iterator<Item = AmpKeyword<'a>> {
398    let full_keywords_iter = full_keywords
399        .iter()
400        .flat_map(|(full_keyword, repeat_for)| {
401            std::iter::repeat_n(Some(full_keyword.as_str()), *repeat_for)
402        })
403        .chain(std::iter::repeat(None)); // In case of insufficient full keywords, just fill in with infinite `None`s
404                                         //
405    keywords
406        .iter()
407        .zip(full_keywords_iter)
408        .enumerate()
409        .map(move |(i, (keyword, full_keyword))| AmpKeyword {
410            rank: i,
411            keyword,
412            full_keyword,
413        })
414}
415
416impl DownloadedAmpSuggestion {
417    pub fn keywords(&self) -> impl Iterator<Item = AmpKeyword<'_>> {
418        iterate_keywords(&self.keywords, &self.full_keywords)
419    }
420
421    pub fn full_keywords_fts_column(&self) -> String {
422        full_keywords_to_fts_content(self.full_keywords.iter().map(|(s, _)| s.as_str()))
423    }
424}
425
426impl DownloadedWikipediaSuggestion {
427    pub fn keywords(&self) -> impl Iterator<Item = AmpKeyword<'_>> {
428        iterate_keywords(&self.keywords, &self.full_keywords)
429    }
430}
431
432#[derive(Debug, PartialEq, Eq)]
433pub(crate) struct AmpKeyword<'a> {
434    pub rank: usize,
435    pub keyword: &'a str,
436    pub full_keyword: Option<&'a str>,
437}
438
439/// An AMO suggestion to ingest from an attachment
440#[derive(Clone, Debug, Deserialize)]
441pub(crate) struct DownloadedAmoSuggestion {
442    pub description: String,
443    pub url: String,
444    pub guid: String,
445    #[serde(rename = "icon")]
446    pub icon_url: String,
447    pub rating: Option<String>,
448    pub number_of_ratings: i64,
449    pub title: String,
450    pub keywords: Vec<String>,
451    pub score: f64,
452}
453/// Yelp location sign data type
454#[derive(Clone, Debug, Deserialize)]
455#[serde(untagged)]
456pub enum DownloadedYelpLocationSign {
457    V1 { keyword: String },
458    V2(String),
459}
460impl ToSql for DownloadedYelpLocationSign {
461    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
462        let keyword = match self {
463            DownloadedYelpLocationSign::V1 { keyword } => keyword,
464            DownloadedYelpLocationSign::V2(keyword) => keyword,
465        };
466        Ok(ToSqlOutput::from(keyword.as_str()))
467    }
468}
469/// A Yelp suggestion to ingest from a Yelp Attachment
470#[derive(Clone, Debug, Deserialize)]
471pub(crate) struct DownloadedYelpSuggestion {
472    pub subjects: Vec<String>,
473    #[serde(rename = "businessSubjects")]
474    pub business_subjects: Option<Vec<String>>,
475    #[serde(rename = "preModifiers")]
476    pub pre_modifiers: Vec<String>,
477    #[serde(rename = "postModifiers")]
478    pub post_modifiers: Vec<String>,
479    #[serde(rename = "locationSigns")]
480    pub location_signs: Vec<DownloadedYelpLocationSign>,
481    #[serde(rename = "yelpModifiers")]
482    pub yelp_modifiers: Vec<String>,
483    #[serde(rename = "icon")]
484    pub icon_id: String,
485    pub score: f64,
486}
487
488/// An MDN suggestion to ingest from an attachment
489#[derive(Clone, Debug, Deserialize)]
490pub(crate) struct DownloadedMdnSuggestion {
491    pub url: String,
492    pub title: String,
493    pub description: String,
494    pub keywords: Vec<String>,
495    pub score: f64,
496}
497
498/// A Fakespot suggestion to ingest from an attachment
499#[derive(Clone, Debug, Deserialize)]
500pub(crate) struct DownloadedFakespotSuggestion {
501    pub fakespot_grade: String,
502    pub product_id: String,
503    pub keywords: String,
504    pub product_type: String,
505    pub rating: f64,
506    pub score: f64,
507    pub title: String,
508    pub total_reviews: i64,
509    pub url: String,
510}
511
512/// A dynamic suggestion record's inline data
513#[derive(Clone, Debug, Deserialize, Serialize)]
514pub(crate) struct DownloadedDynamicRecord {
515    pub suggestion_type: String,
516    pub score: Option<f64>,
517}
518
519/// A dynamic suggestion to ingest from an attachment
520#[derive(Clone, Debug, Deserialize)]
521pub(crate) struct DownloadedDynamicSuggestion {
522    keywords: Vec<FullOrPrefixKeywords<String>>,
523    pub dismissal_key: Option<String>,
524    pub data: Option<Value>,
525}
526
527impl DownloadedDynamicSuggestion {
528    /// Iterate over all keywords for this suggestion. Iteration may contain
529    /// duplicate keywords depending on the structure of the data, so do not
530    /// assume keywords are unique. Duplicates are not filtered out because
531    /// doing so would require O(number of keywords) space, and the number of
532    /// keywords can be very large. If you are inserting into the store, rely on
533    /// uniqueness constraints and use `INSERT OR IGNORE`.
534    pub fn keywords(&self) -> impl Iterator<Item = String> + '_ {
535        self.keywords.iter().flat_map(|e| e.keywords())
536    }
537}
538
539/// A single full keyword or a `(prefix, suffixes)` tuple representing multiple
540/// prefix keywords. Prefix keywords are enumerated by appending to `prefix`
541/// each possible prefix of each suffix, including the full suffix. The prefix
542/// is also enumerated by itself. Examples:
543///
544/// `FullOrPrefixKeywords::Full("some full keyword")`
545/// => "some full keyword"
546///
547/// `FullOrPrefixKeywords::Prefix(("sug", vec!["gest", "arplum"]))`
548/// => "sug"
549///    "sugg"
550///    "sugge"
551///    "sugges"
552///    "suggest"
553///    "suga"
554///    "sugar"
555///    "sugarp"
556///    "sugarpl"
557///    "sugarplu"
558///    "sugarplum"
559#[derive(Clone, Debug, Deserialize)]
560#[serde(untagged)]
561enum FullOrPrefixKeywords<T> {
562    Full(T),
563    Prefix((T, Vec<T>)),
564}
565
566impl<T> From<T> for FullOrPrefixKeywords<T> {
567    fn from(full_keyword: T) -> Self {
568        Self::Full(full_keyword)
569    }
570}
571
572impl<T> From<(T, Vec<T>)> for FullOrPrefixKeywords<T> {
573    fn from(prefix_suffixes: (T, Vec<T>)) -> Self {
574        Self::Prefix(prefix_suffixes)
575    }
576}
577
578impl FullOrPrefixKeywords<String> {
579    pub fn keywords(&self) -> Box<dyn Iterator<Item = String> + '_> {
580        match self {
581            FullOrPrefixKeywords::Full(kw) => Box::new(std::iter::once(kw.to_owned())),
582            FullOrPrefixKeywords::Prefix((prefix, suffixes)) => Box::new(
583                std::iter::once(prefix.to_owned()).chain(suffixes.iter().flat_map(|suffix| {
584                    let mut kw = prefix.clone();
585                    suffix.chars().map(move |c| {
586                        kw.push(c);
587                        kw.clone()
588                    })
589                })),
590            ),
591        }
592    }
593}
594
595/// Global Suggest configuration data to ingest from a configuration record
596#[derive(Clone, Debug, Deserialize, Serialize)]
597pub(crate) struct DownloadedGlobalConfig {
598    pub configuration: DownloadedGlobalConfigInner,
599}
600#[derive(Clone, Debug, Deserialize, Serialize)]
601pub(crate) struct DownloadedGlobalConfigInner {
602    /// The maximum number of times the user can click "Show less frequently"
603    /// for a suggestion in the UI.
604    pub show_less_frequently_cap: i32,
605}
606
607#[cfg(test)]
608mod test {
609    use super::*;
610
611    #[test]
612    fn test_full_keywords() {
613        let suggestion = DownloadedAmpSuggestion {
614            keywords: vec![
615                String::from("f"),
616                String::from("fo"),
617                String::from("foo"),
618                String::from("foo b"),
619                String::from("foo ba"),
620                String::from("foo bar"),
621            ],
622            full_keywords: vec![(String::from("foo"), 3), (String::from("foo bar"), 3)],
623            ..DownloadedAmpSuggestion::default()
624        };
625
626        assert_eq!(
627            Vec::from_iter(suggestion.keywords()),
628            vec![
629                AmpKeyword {
630                    rank: 0,
631                    keyword: "f",
632                    full_keyword: Some("foo"),
633                },
634                AmpKeyword {
635                    rank: 1,
636                    keyword: "fo",
637                    full_keyword: Some("foo"),
638                },
639                AmpKeyword {
640                    rank: 2,
641                    keyword: "foo",
642                    full_keyword: Some("foo"),
643                },
644                AmpKeyword {
645                    rank: 3,
646                    keyword: "foo b",
647                    full_keyword: Some("foo bar"),
648                },
649                AmpKeyword {
650                    rank: 4,
651                    keyword: "foo ba",
652                    full_keyword: Some("foo bar"),
653                },
654                AmpKeyword {
655                    rank: 5,
656                    keyword: "foo bar",
657                    full_keyword: Some("foo bar"),
658                },
659            ],
660        );
661    }
662
663    #[test]
664    fn test_missing_full_keywords() {
665        let suggestion = DownloadedAmpSuggestion {
666            keywords: vec![
667                String::from("f"),
668                String::from("fo"),
669                String::from("foo"),
670                String::from("foo b"),
671                String::from("foo ba"),
672                String::from("foo bar"),
673            ],
674            // Only the first 3 keywords have full keywords associated with them
675            full_keywords: vec![(String::from("foo"), 3)],
676            ..DownloadedAmpSuggestion::default()
677        };
678
679        assert_eq!(
680            Vec::from_iter(suggestion.keywords()),
681            vec![
682                AmpKeyword {
683                    rank: 0,
684                    keyword: "f",
685                    full_keyword: Some("foo"),
686                },
687                AmpKeyword {
688                    rank: 1,
689                    keyword: "fo",
690                    full_keyword: Some("foo"),
691                },
692                AmpKeyword {
693                    rank: 2,
694                    keyword: "foo",
695                    full_keyword: Some("foo"),
696                },
697                AmpKeyword {
698                    rank: 3,
699                    keyword: "foo b",
700                    full_keyword: None,
701                },
702                AmpKeyword {
703                    rank: 4,
704                    keyword: "foo ba",
705                    full_keyword: None,
706                },
707                AmpKeyword {
708                    rank: 5,
709                    keyword: "foo bar",
710                    full_keyword: None,
711                },
712            ],
713        );
714    }
715
716    fn full_or_prefix_keywords_to_owned(
717        kws: Vec<FullOrPrefixKeywords<&str>>,
718    ) -> Vec<FullOrPrefixKeywords<String>> {
719        kws.iter()
720            .map(|val| match val {
721                FullOrPrefixKeywords::Full(s) => FullOrPrefixKeywords::Full(s.to_string()),
722                FullOrPrefixKeywords::Prefix((prefix, suffixes)) => FullOrPrefixKeywords::Prefix((
723                    prefix.to_string(),
724                    suffixes.iter().map(|s| s.to_string()).collect(),
725                )),
726            })
727            .collect()
728    }
729
730    #[test]
731    fn test_dynamic_keywords() {
732        let suggestion = DownloadedDynamicSuggestion {
733            keywords: full_or_prefix_keywords_to_owned(vec![
734                "no suffixes".into(),
735                ("empty suffixes", vec![]).into(),
736                ("empty string suffix", vec![""]).into(),
737                ("choco", vec!["", "bo", "late"]).into(),
738                "duplicate 1".into(),
739                "duplicate 1".into(),
740                ("dup", vec!["licate 1", "licate 2"]).into(),
741                ("dup", vec!["lo", "licate 2", "licate 3"]).into(),
742                ("duplic", vec!["ate 3", "ar", "ate 4"]).into(),
743                ("du", vec!["plicate 4", "plicate 5", "nk"]).into(),
744            ]),
745            data: None,
746            dismissal_key: None,
747        };
748
749        assert_eq!(
750            Vec::from_iter(suggestion.keywords()),
751            vec![
752                "no suffixes",
753                "empty suffixes",
754                "empty string suffix",
755                "choco",
756                "chocob",
757                "chocobo",
758                "chocol",
759                "chocola",
760                "chocolat",
761                "chocolate",
762                "duplicate 1",
763                "duplicate 1",
764                "dup",
765                "dupl",
766                "dupli",
767                "duplic",
768                "duplica",
769                "duplicat",
770                "duplicate",
771                "duplicate ",
772                "duplicate 1",
773                "dupl",
774                "dupli",
775                "duplic",
776                "duplica",
777                "duplicat",
778                "duplicate",
779                "duplicate ",
780                "duplicate 2",
781                "dup",
782                "dupl",
783                "duplo",
784                "dupl",
785                "dupli",
786                "duplic",
787                "duplica",
788                "duplicat",
789                "duplicate",
790                "duplicate ",
791                "duplicate 2",
792                "dupl",
793                "dupli",
794                "duplic",
795                "duplica",
796                "duplicat",
797                "duplicate",
798                "duplicate ",
799                "duplicate 3",
800                "duplic",
801                "duplica",
802                "duplicat",
803                "duplicate",
804                "duplicate ",
805                "duplicate 3",
806                "duplica",
807                "duplicar",
808                "duplica",
809                "duplicat",
810                "duplicate",
811                "duplicate ",
812                "duplicate 4",
813                "du",
814                "dup",
815                "dupl",
816                "dupli",
817                "duplic",
818                "duplica",
819                "duplicat",
820                "duplicate",
821                "duplicate ",
822                "duplicate 4",
823                "dup",
824                "dupl",
825                "dupli",
826                "duplic",
827                "duplica",
828                "duplicat",
829                "duplicate",
830                "duplicate ",
831                "duplicate 5",
832                "dun",
833                "dunk",
834            ],
835        );
836    }
837}