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 click_url: String,
374    pub impression_url: String,
375    #[serde(rename = "icon")]
376    pub icon_id: String,
377}
378
379/// A Wikipedia suggestion to ingest from a Wikipedia attachment.
380#[derive(Clone, Debug, Default, Deserialize)]
381pub(crate) struct DownloadedWikipediaSuggestion {
382    pub keywords: Vec<String>,
383    pub title: String,
384    pub url: String,
385    pub score: Option<f64>,
386    #[serde(default)]
387    pub full_keywords: Vec<(String, usize)>,
388    #[serde(rename = "icon")]
389    pub icon_id: String,
390}
391
392/// Iterate over all AMP/Wikipedia-style keywords.
393pub fn iterate_keywords<'a>(
394    keywords: &'a [String],
395    full_keywords: &'a [(String, usize)],
396) -> impl Iterator<Item = AmpKeyword<'a>> {
397    let full_keywords_iter = full_keywords
398        .iter()
399        .flat_map(|(full_keyword, repeat_for)| {
400            std::iter::repeat_n(Some(full_keyword.as_str()), *repeat_for)
401        })
402        .chain(std::iter::repeat(None)); // In case of insufficient full keywords, just fill in with infinite `None`s
403                                         //
404    keywords
405        .iter()
406        .zip(full_keywords_iter)
407        .enumerate()
408        .map(move |(i, (keyword, full_keyword))| AmpKeyword {
409            rank: i,
410            keyword,
411            full_keyword,
412        })
413}
414
415impl DownloadedAmpSuggestion {
416    pub fn keywords(&self) -> impl Iterator<Item = AmpKeyword<'_>> {
417        iterate_keywords(&self.keywords, &self.full_keywords)
418    }
419
420    pub fn full_keywords_fts_column(&self) -> String {
421        full_keywords_to_fts_content(self.full_keywords.iter().map(|(s, _)| s.as_str()))
422    }
423}
424
425impl DownloadedWikipediaSuggestion {
426    pub fn keywords(&self) -> impl Iterator<Item = AmpKeyword<'_>> {
427        iterate_keywords(&self.keywords, &self.full_keywords)
428    }
429}
430
431#[derive(Debug, PartialEq, Eq)]
432pub(crate) struct AmpKeyword<'a> {
433    pub rank: usize,
434    pub keyword: &'a str,
435    pub full_keyword: Option<&'a str>,
436}
437
438/// An AMO suggestion to ingest from an attachment
439#[derive(Clone, Debug, Deserialize)]
440pub(crate) struct DownloadedAmoSuggestion {
441    pub description: String,
442    pub url: String,
443    pub guid: String,
444    #[serde(rename = "icon")]
445    pub icon_url: String,
446    pub rating: Option<String>,
447    pub number_of_ratings: i64,
448    pub title: String,
449    pub keywords: Vec<String>,
450    pub score: f64,
451}
452/// Yelp location sign data type
453#[derive(Clone, Debug, Deserialize)]
454#[serde(untagged)]
455pub enum DownloadedYelpLocationSign {
456    V1 { keyword: String },
457    V2(String),
458}
459impl ToSql for DownloadedYelpLocationSign {
460    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
461        let keyword = match self {
462            DownloadedYelpLocationSign::V1 { keyword } => keyword,
463            DownloadedYelpLocationSign::V2(keyword) => keyword,
464        };
465        Ok(ToSqlOutput::from(keyword.as_str()))
466    }
467}
468/// A Yelp suggestion to ingest from a Yelp Attachment
469#[derive(Clone, Debug, Deserialize)]
470pub(crate) struct DownloadedYelpSuggestion {
471    pub subjects: Vec<String>,
472    #[serde(rename = "businessSubjects")]
473    pub business_subjects: Option<Vec<String>>,
474    #[serde(rename = "preModifiers")]
475    pub pre_modifiers: Vec<String>,
476    #[serde(rename = "postModifiers")]
477    pub post_modifiers: Vec<String>,
478    #[serde(rename = "locationSigns")]
479    pub location_signs: Vec<DownloadedYelpLocationSign>,
480    #[serde(rename = "yelpModifiers")]
481    pub yelp_modifiers: Vec<String>,
482    #[serde(rename = "icon")]
483    pub icon_id: String,
484    pub score: f64,
485}
486
487/// An MDN suggestion to ingest from an attachment
488#[derive(Clone, Debug, Deserialize)]
489pub(crate) struct DownloadedMdnSuggestion {
490    pub url: String,
491    pub title: String,
492    pub description: String,
493    pub keywords: Vec<String>,
494    pub score: f64,
495}
496
497/// A Fakespot suggestion to ingest from an attachment
498#[derive(Clone, Debug, Deserialize)]
499pub(crate) struct DownloadedFakespotSuggestion {
500    pub fakespot_grade: String,
501    pub product_id: String,
502    pub keywords: String,
503    pub product_type: String,
504    pub rating: f64,
505    pub score: f64,
506    pub title: String,
507    pub total_reviews: i64,
508    pub url: String,
509}
510
511/// A dynamic suggestion record's inline data
512#[derive(Clone, Debug, Deserialize, Serialize)]
513pub(crate) struct DownloadedDynamicRecord {
514    pub suggestion_type: String,
515    pub score: Option<f64>,
516}
517
518/// A dynamic suggestion to ingest from an attachment
519#[derive(Clone, Debug, Deserialize)]
520pub(crate) struct DownloadedDynamicSuggestion {
521    keywords: Vec<FullOrPrefixKeywords<String>>,
522    pub dismissal_key: Option<String>,
523    pub data: Option<Value>,
524}
525
526impl DownloadedDynamicSuggestion {
527    /// Iterate over all keywords for this suggestion. Iteration may contain
528    /// duplicate keywords depending on the structure of the data, so do not
529    /// assume keywords are unique. Duplicates are not filtered out because
530    /// doing so would require O(number of keywords) space, and the number of
531    /// keywords can be very large. If you are inserting into the store, rely on
532    /// uniqueness constraints and use `INSERT OR IGNORE`.
533    pub fn keywords(&self) -> impl Iterator<Item = String> + '_ {
534        self.keywords.iter().flat_map(|e| e.keywords())
535    }
536}
537
538/// A single full keyword or a `(prefix, suffixes)` tuple representing multiple
539/// prefix keywords. Prefix keywords are enumerated by appending to `prefix`
540/// each possible prefix of each suffix, including the full suffix. The prefix
541/// is also enumerated by itself. Examples:
542///
543/// `FullOrPrefixKeywords::Full("some full keyword")`
544/// => "some full keyword"
545///
546/// `FullOrPrefixKeywords::Prefix(("sug", vec!["gest", "arplum"]))`
547/// => "sug"
548///    "sugg"
549///    "sugge"
550///    "sugges"
551///    "suggest"
552///    "suga"
553///    "sugar"
554///    "sugarp"
555///    "sugarpl"
556///    "sugarplu"
557///    "sugarplum"
558#[derive(Clone, Debug, Deserialize)]
559#[serde(untagged)]
560enum FullOrPrefixKeywords<T> {
561    Full(T),
562    Prefix((T, Vec<T>)),
563}
564
565impl<T> From<T> for FullOrPrefixKeywords<T> {
566    fn from(full_keyword: T) -> Self {
567        Self::Full(full_keyword)
568    }
569}
570
571impl<T> From<(T, Vec<T>)> for FullOrPrefixKeywords<T> {
572    fn from(prefix_suffixes: (T, Vec<T>)) -> Self {
573        Self::Prefix(prefix_suffixes)
574    }
575}
576
577impl FullOrPrefixKeywords<String> {
578    pub fn keywords(&self) -> Box<dyn Iterator<Item = String> + '_> {
579        match self {
580            FullOrPrefixKeywords::Full(kw) => Box::new(std::iter::once(kw.to_owned())),
581            FullOrPrefixKeywords::Prefix((prefix, suffixes)) => Box::new(
582                std::iter::once(prefix.to_owned()).chain(suffixes.iter().flat_map(|suffix| {
583                    let mut kw = prefix.clone();
584                    suffix.chars().map(move |c| {
585                        kw.push(c);
586                        kw.clone()
587                    })
588                })),
589            ),
590        }
591    }
592}
593
594/// Global Suggest configuration data to ingest from a configuration record
595#[derive(Clone, Debug, Deserialize, Serialize)]
596pub(crate) struct DownloadedGlobalConfig {
597    pub configuration: DownloadedGlobalConfigInner,
598}
599#[derive(Clone, Debug, Deserialize, Serialize)]
600pub(crate) struct DownloadedGlobalConfigInner {
601    /// The maximum number of times the user can click "Show less frequently"
602    /// for a suggestion in the UI.
603    pub show_less_frequently_cap: i32,
604}
605
606#[cfg(test)]
607mod test {
608    use super::*;
609
610    #[test]
611    fn test_full_keywords() {
612        let suggestion = DownloadedAmpSuggestion {
613            keywords: vec![
614                String::from("f"),
615                String::from("fo"),
616                String::from("foo"),
617                String::from("foo b"),
618                String::from("foo ba"),
619                String::from("foo bar"),
620            ],
621            full_keywords: vec![(String::from("foo"), 3), (String::from("foo bar"), 3)],
622            ..DownloadedAmpSuggestion::default()
623        };
624
625        assert_eq!(
626            Vec::from_iter(suggestion.keywords()),
627            vec![
628                AmpKeyword {
629                    rank: 0,
630                    keyword: "f",
631                    full_keyword: Some("foo"),
632                },
633                AmpKeyword {
634                    rank: 1,
635                    keyword: "fo",
636                    full_keyword: Some("foo"),
637                },
638                AmpKeyword {
639                    rank: 2,
640                    keyword: "foo",
641                    full_keyword: Some("foo"),
642                },
643                AmpKeyword {
644                    rank: 3,
645                    keyword: "foo b",
646                    full_keyword: Some("foo bar"),
647                },
648                AmpKeyword {
649                    rank: 4,
650                    keyword: "foo ba",
651                    full_keyword: Some("foo bar"),
652                },
653                AmpKeyword {
654                    rank: 5,
655                    keyword: "foo bar",
656                    full_keyword: Some("foo bar"),
657                },
658            ],
659        );
660    }
661
662    #[test]
663    fn test_missing_full_keywords() {
664        let suggestion = DownloadedAmpSuggestion {
665            keywords: vec![
666                String::from("f"),
667                String::from("fo"),
668                String::from("foo"),
669                String::from("foo b"),
670                String::from("foo ba"),
671                String::from("foo bar"),
672            ],
673            // Only the first 3 keywords have full keywords associated with them
674            full_keywords: vec![(String::from("foo"), 3)],
675            ..DownloadedAmpSuggestion::default()
676        };
677
678        assert_eq!(
679            Vec::from_iter(suggestion.keywords()),
680            vec![
681                AmpKeyword {
682                    rank: 0,
683                    keyword: "f",
684                    full_keyword: Some("foo"),
685                },
686                AmpKeyword {
687                    rank: 1,
688                    keyword: "fo",
689                    full_keyword: Some("foo"),
690                },
691                AmpKeyword {
692                    rank: 2,
693                    keyword: "foo",
694                    full_keyword: Some("foo"),
695                },
696                AmpKeyword {
697                    rank: 3,
698                    keyword: "foo b",
699                    full_keyword: None,
700                },
701                AmpKeyword {
702                    rank: 4,
703                    keyword: "foo ba",
704                    full_keyword: None,
705                },
706                AmpKeyword {
707                    rank: 5,
708                    keyword: "foo bar",
709                    full_keyword: None,
710                },
711            ],
712        );
713    }
714
715    fn full_or_prefix_keywords_to_owned(
716        kws: Vec<FullOrPrefixKeywords<&str>>,
717    ) -> Vec<FullOrPrefixKeywords<String>> {
718        kws.iter()
719            .map(|val| match val {
720                FullOrPrefixKeywords::Full(s) => FullOrPrefixKeywords::Full(s.to_string()),
721                FullOrPrefixKeywords::Prefix((prefix, suffixes)) => FullOrPrefixKeywords::Prefix((
722                    prefix.to_string(),
723                    suffixes.iter().map(|s| s.to_string()).collect(),
724                )),
725            })
726            .collect()
727    }
728
729    #[test]
730    fn test_dynamic_keywords() {
731        let suggestion = DownloadedDynamicSuggestion {
732            keywords: full_or_prefix_keywords_to_owned(vec![
733                "no suffixes".into(),
734                ("empty suffixes", vec![]).into(),
735                ("empty string suffix", vec![""]).into(),
736                ("choco", vec!["", "bo", "late"]).into(),
737                "duplicate 1".into(),
738                "duplicate 1".into(),
739                ("dup", vec!["licate 1", "licate 2"]).into(),
740                ("dup", vec!["lo", "licate 2", "licate 3"]).into(),
741                ("duplic", vec!["ate 3", "ar", "ate 4"]).into(),
742                ("du", vec!["plicate 4", "plicate 5", "nk"]).into(),
743            ]),
744            data: None,
745            dismissal_key: None,
746        };
747
748        assert_eq!(
749            Vec::from_iter(suggestion.keywords()),
750            vec![
751                "no suffixes",
752                "empty suffixes",
753                "empty string suffix",
754                "choco",
755                "chocob",
756                "chocobo",
757                "chocol",
758                "chocola",
759                "chocolat",
760                "chocolate",
761                "duplicate 1",
762                "duplicate 1",
763                "dup",
764                "dupl",
765                "dupli",
766                "duplic",
767                "duplica",
768                "duplicat",
769                "duplicate",
770                "duplicate ",
771                "duplicate 1",
772                "dupl",
773                "dupli",
774                "duplic",
775                "duplica",
776                "duplicat",
777                "duplicate",
778                "duplicate ",
779                "duplicate 2",
780                "dup",
781                "dupl",
782                "duplo",
783                "dupl",
784                "dupli",
785                "duplic",
786                "duplica",
787                "duplicat",
788                "duplicate",
789                "duplicate ",
790                "duplicate 2",
791                "dupl",
792                "dupli",
793                "duplic",
794                "duplica",
795                "duplicat",
796                "duplicate",
797                "duplicate ",
798                "duplicate 3",
799                "duplic",
800                "duplica",
801                "duplicat",
802                "duplicate",
803                "duplicate ",
804                "duplicate 3",
805                "duplica",
806                "duplicar",
807                "duplica",
808                "duplicat",
809                "duplicate",
810                "duplicate ",
811                "duplicate 4",
812                "du",
813                "dup",
814                "dupl",
815                "dupli",
816                "duplic",
817                "duplica",
818                "duplicat",
819                "duplicate",
820                "duplicate ",
821                "duplicate 4",
822                "dup",
823                "dupl",
824                "dupli",
825                "duplic",
826                "duplica",
827                "duplicat",
828                "duplicate",
829                "duplicate ",
830                "duplicate 5",
831                "dun",
832                "dunk",
833            ],
834        );
835    }
836}