suggest/
suggestion.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
6use chrono::Local;
7
8use crate::{db::DEFAULT_SUGGESTION_SCORE, geoname::Geoname};
9
10/// The template parameter for a timestamp in a "raw" sponsored suggestion URL.
11const TIMESTAMP_TEMPLATE: &str = "%YYYYMMDDHH%";
12
13/// The length, in bytes, of a timestamp in a "cooked" sponsored suggestion URL.
14///
15/// Cooked timestamps don't include the leading or trailing `%`, so this is
16/// 2 bytes shorter than [`TIMESTAMP_TEMPLATE`].
17const TIMESTAMP_LENGTH: usize = 10;
18
19/// Subject type for Yelp suggestion.
20#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, uniffi::Enum)]
21#[repr(u8)]
22pub enum YelpSubjectType {
23    // Service such as sushi, ramen, yoga etc.
24    Service = 0,
25    // Specific business such as the shop name.
26    Business = 1,
27}
28
29/// A suggestion from the database to show in the address bar.
30#[derive(Clone, Debug, PartialEq, uniffi::Enum)]
31pub enum Suggestion {
32    Amp {
33        title: String,
34        url: String,
35        raw_url: String,
36        icon: Option<Vec<u8>>,
37        icon_mimetype: Option<String>,
38        full_keyword: String,
39        block_id: i64,
40        advertiser: String,
41        iab_category: String,
42        categories: Vec<i32>,
43        impression_url: String,
44        click_url: String,
45        raw_click_url: String,
46        score: f64,
47        fts_match_info: Option<FtsMatchInfo>,
48    },
49    Wikipedia {
50        title: String,
51        url: String,
52        icon: Option<Vec<u8>>,
53        icon_mimetype: Option<String>,
54        full_keyword: String,
55    },
56    Amo {
57        title: String,
58        url: String,
59        icon_url: String,
60        description: String,
61        rating: Option<String>,
62        number_of_ratings: i64,
63        guid: String,
64        score: f64,
65    },
66    Yelp {
67        url: String,
68        title: String,
69        icon: Option<Vec<u8>>,
70        icon_mimetype: Option<String>,
71        score: f64,
72        has_location_sign: bool,
73        subject_exact_match: bool,
74        subject_type: YelpSubjectType,
75        location_param: String,
76    },
77    Mdn {
78        title: String,
79        url: String,
80        description: String,
81        score: f64,
82    },
83    Weather {
84        city: Option<Geoname>,
85        score: f64,
86    },
87    Fakespot {
88        fakespot_grade: String,
89        product_id: String,
90        rating: f64,
91        title: String,
92        total_reviews: i64,
93        url: String,
94        icon: Option<Vec<u8>>,
95        icon_mimetype: Option<String>,
96        score: f64,
97        // Details about the FTS match.  For performance reasons, this is only calculated for the
98        // result with the highest score.  We assume that only one that will be shown to the user
99        // and therefore the only one we'll collect metrics for.
100        match_info: Option<FtsMatchInfo>,
101    },
102    Dynamic {
103        suggestion_type: String,
104        data: Option<serde_json::Value>,
105        /// This value is optionally defined in the suggestion's remote settings
106        /// data and is an opaque token used for dismissing the suggestion in
107        /// lieu of a URL. If `Some`, the suggestion can be dismissed by passing
108        /// the wrapped string to [crate::SuggestStore::dismiss_suggestion].
109        dismissal_key: Option<String>,
110        score: f64,
111    },
112}
113
114/// Additional data about how an FTS match was made
115#[derive(Debug, Clone, PartialEq, uniffi::Record)]
116pub struct FtsMatchInfo {
117    /// Was this a prefix match (`water b` matched against `water bottle`)
118    pub prefix: bool,
119    /// Did the match require stemming? (`run shoes` matched against `running shoes`)
120    pub stemming: bool,
121}
122
123impl PartialOrd for Suggestion {
124    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
125        Some(self.cmp(other))
126    }
127}
128
129impl Ord for Suggestion {
130    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
131        other
132            .score()
133            .partial_cmp(&self.score())
134            .unwrap_or(std::cmp::Ordering::Equal)
135    }
136}
137
138impl Suggestion {
139    /// Get the suggestion's dismissal key, which should be stored in the
140    /// `dismissed_suggestions` table when the suggestion is dismissed. Some
141    /// suggestions may not have dismissal keys and cannot be dismissed.
142    pub fn dismissal_key(&self) -> Option<&str> {
143        match self {
144            Self::Amp { full_keyword, .. } => {
145                if !full_keyword.is_empty() {
146                    Some(full_keyword)
147                } else {
148                    self.raw_url()
149                }
150            }
151            Self::Dynamic { dismissal_key, .. } => dismissal_key.as_deref(),
152            Self::Wikipedia { .. }
153            | Self::Amo { .. }
154            | Self::Yelp { .. }
155            | Self::Mdn { .. }
156            | Self::Weather { .. }
157            | Self::Fakespot { .. } => self.raw_url(),
158        }
159    }
160
161    /// Get the URL for this suggestion, if present
162    pub fn url(&self) -> Option<&str> {
163        match self {
164            Self::Amp { url, .. }
165            | Self::Wikipedia { url, .. }
166            | Self::Amo { url, .. }
167            | Self::Yelp { url, .. }
168            | Self::Mdn { url, .. }
169            | Self::Fakespot { url, .. } => Some(url),
170            Self::Weather { .. } | Self::Dynamic { .. } => None,
171        }
172    }
173
174    /// Get the raw URL for this suggestion, if present
175    ///
176    /// This is the same as `url` except for Amp.  In that case, `url` is the URL after being
177    /// "cooked" using template interpolation, while `raw_url` is the URL template.
178    pub fn raw_url(&self) -> Option<&str> {
179        match self {
180            Self::Amp { raw_url, .. } => Some(raw_url),
181            Self::Wikipedia { .. }
182            | Self::Amo { .. }
183            | Self::Yelp { .. }
184            | Self::Mdn { .. }
185            | Self::Weather { .. }
186            | Self::Fakespot { .. }
187            | Self::Dynamic { .. } => self.url(),
188        }
189    }
190
191    pub fn title(&self) -> &str {
192        match self {
193            Self::Amp { title, .. }
194            | Self::Wikipedia { title, .. }
195            | Self::Amo { title, .. }
196            | Self::Yelp { title, .. }
197            | Self::Mdn { title, .. }
198            | Self::Fakespot { title, .. } => title,
199            _ => "untitled",
200        }
201    }
202
203    pub fn icon_data(&self) -> Option<&[u8]> {
204        match self {
205            Self::Amp { icon, .. }
206            | Self::Wikipedia { icon, .. }
207            | Self::Yelp { icon, .. }
208            | Self::Fakespot { icon, .. } => icon.as_deref(),
209            _ => None,
210        }
211    }
212
213    pub fn score(&self) -> f64 {
214        match self {
215            Self::Amp { score, .. }
216            | Self::Amo { score, .. }
217            | Self::Yelp { score, .. }
218            | Self::Mdn { score, .. }
219            | Self::Weather { score, .. }
220            | Self::Fakespot { score, .. }
221            | Self::Dynamic { score, .. } => *score,
222            Self::Wikipedia { .. } => DEFAULT_SUGGESTION_SCORE,
223        }
224    }
225
226    pub fn fts_match_info(&self) -> Option<&FtsMatchInfo> {
227        match self {
228            Self::Fakespot { match_info, .. } => match_info.as_ref(),
229            _ => None,
230        }
231    }
232}
233
234#[cfg(test)]
235/// Testing utilitise
236impl Suggestion {
237    pub fn with_fakespot_keyword_bonus(mut self) -> Self {
238        match &mut self {
239            Self::Fakespot { score, .. } => {
240                *score += 0.01;
241            }
242            _ => panic!("Not Suggestion::Fakespot"),
243        }
244        self
245    }
246
247    pub fn with_fakespot_product_type_bonus(mut self, bonus: f64) -> Self {
248        match &mut self {
249            Self::Fakespot { score, .. } => {
250                *score += 0.001 * bonus;
251            }
252            _ => panic!("Not Suggestion::Fakespot"),
253        }
254        self
255    }
256}
257
258impl Eq for Suggestion {}
259/// Replaces all template parameters in a "raw" sponsored suggestion URL,
260/// producing a "cooked" URL with real values.
261pub(crate) fn cook_raw_suggestion_url(raw_url: &str) -> String {
262    let timestamp = Local::now().format("%Y%m%d%H").to_string();
263    debug_assert!(timestamp.len() == TIMESTAMP_LENGTH);
264    // "Raw" sponsored suggestion URLs must not contain more than one timestamp
265    // template parameter, so we replace just the first occurrence.
266    raw_url.replacen(TIMESTAMP_TEMPLATE, &timestamp, 1)
267}
268
269/// Determines whether a "raw" sponsored suggestion URL is equivalent to a
270/// "cooked" URL. The two URLs are equivalent if they are identical except for
271/// their replaced template parameters, which can be different.
272#[uniffi::export]
273pub fn raw_suggestion_url_matches(raw_url: &str, cooked_url: &str) -> bool {
274    let Some((raw_url_prefix, raw_url_suffix)) = raw_url.split_once(TIMESTAMP_TEMPLATE) else {
275        return raw_url == cooked_url;
276    };
277    let (Some(cooked_url_prefix), Some(cooked_url_suffix)) = (
278        cooked_url.get(..raw_url_prefix.len()),
279        cooked_url.get(raw_url_prefix.len() + TIMESTAMP_LENGTH..),
280    ) else {
281        return false;
282    };
283    if raw_url_prefix != cooked_url_prefix || raw_url_suffix != cooked_url_suffix {
284        return false;
285    }
286    let maybe_timestamp =
287        &cooked_url[raw_url_prefix.len()..raw_url_prefix.len() + TIMESTAMP_LENGTH];
288    maybe_timestamp.bytes().all(|b| b.is_ascii_digit())
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn cook_url_with_template_parameters() {
297        let raw_url_with_one_timestamp = "https://example.com?a=%YYYYMMDDHH%";
298        let cooked_url_with_one_timestamp = cook_raw_suggestion_url(raw_url_with_one_timestamp);
299        assert_eq!(
300            cooked_url_with_one_timestamp.len(),
301            raw_url_with_one_timestamp.len() - 2
302        );
303        assert_ne!(raw_url_with_one_timestamp, cooked_url_with_one_timestamp);
304
305        let raw_url_with_trailing_segment = "https://example.com?a=%YYYYMMDDHH%&b=c";
306        let cooked_url_with_trailing_segment =
307            cook_raw_suggestion_url(raw_url_with_trailing_segment);
308        assert_eq!(
309            cooked_url_with_trailing_segment.len(),
310            raw_url_with_trailing_segment.len() - 2
311        );
312        assert_ne!(
313            raw_url_with_trailing_segment,
314            cooked_url_with_trailing_segment
315        );
316    }
317
318    #[test]
319    fn cook_url_without_template_parameters() {
320        let raw_url_without_timestamp = "https://example.com?b=c";
321        let cooked_url_without_timestamp = cook_raw_suggestion_url(raw_url_without_timestamp);
322        assert_eq!(raw_url_without_timestamp, cooked_url_without_timestamp);
323    }
324
325    #[test]
326    fn url_with_template_parameters_matches() {
327        let raw_url_with_one_timestamp = "https://example.com?a=%YYYYMMDDHH%";
328        let raw_url_with_trailing_segment = "https://example.com?a=%YYYYMMDDHH%&b=c";
329
330        // Equivalent, except for their replaced template parameters.
331        assert!(raw_suggestion_url_matches(
332            raw_url_with_one_timestamp,
333            "https://example.com?a=0000000000"
334        ));
335        assert!(raw_suggestion_url_matches(
336            raw_url_with_trailing_segment,
337            "https://example.com?a=1111111111&b=c"
338        ));
339
340        // Different lengths.
341        assert!(!raw_suggestion_url_matches(
342            raw_url_with_one_timestamp,
343            "https://example.com?a=1234567890&c=d"
344        ));
345        assert!(!raw_suggestion_url_matches(
346            raw_url_with_one_timestamp,
347            "https://example.com?a=123456789"
348        ));
349        assert!(!raw_suggestion_url_matches(
350            raw_url_with_trailing_segment,
351            "https://example.com?a=0987654321"
352        ));
353        assert!(!raw_suggestion_url_matches(
354            raw_url_with_trailing_segment,
355            "https://example.com?a=0987654321&b=c&d=e"
356        ));
357
358        // Different query parameter names.
359        assert!(!raw_suggestion_url_matches(
360            raw_url_with_one_timestamp,         // `a`.
361            "https://example.com?b=4444444444"  // `b`.
362        ));
363        assert!(!raw_suggestion_url_matches(
364            raw_url_with_trailing_segment,          // `a&b`.
365            "https://example.com?a=5555555555&c=c"  // `a&c`.
366        ));
367
368        // Not a timestamp.
369        assert!(!raw_suggestion_url_matches(
370            raw_url_with_one_timestamp,
371            "https://example.com?a=bcdefghijk"
372        ));
373        assert!(!raw_suggestion_url_matches(
374            raw_url_with_trailing_segment,
375            "https://example.com?a=bcdefghijk&b=c"
376        ));
377    }
378
379    #[test]
380    fn url_without_template_parameters_matches() {
381        let raw_url_without_timestamp = "https://example.com?b=c";
382
383        assert!(raw_suggestion_url_matches(
384            raw_url_without_timestamp,
385            "https://example.com?b=c"
386        ));
387        assert!(!raw_suggestion_url_matches(
388            raw_url_without_timestamp,
389            "http://example.com"
390        ));
391        assert!(!raw_suggestion_url_matches(
392            raw_url_without_timestamp, // `a`.
393            "http://example.com?a=c"   // `b`.
394        ));
395        assert!(!raw_suggestion_url_matches(
396            raw_url_without_timestamp,
397            "https://example.com?b=c&d=e"
398        ));
399    }
400}