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