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    Dynamic {
88        suggestion_type: String,
89        data: Option<serde_json::Value>,
90        /// This value is optionally defined in the suggestion's remote settings
91        /// data and is an opaque token used for dismissing the suggestion in
92        /// lieu of a URL. If `Some`, the suggestion can be dismissed by passing
93        /// the wrapped string to [crate::SuggestStore::dismiss_suggestion].
94        dismissal_key: Option<String>,
95        score: f64,
96    },
97}
98
99/// Additional data about how an FTS match was made
100#[derive(Debug, Clone, PartialEq, uniffi::Record)]
101pub struct FtsMatchInfo {
102    /// Was this a prefix match (`water b` matched against `water bottle`)
103    pub prefix: bool,
104    /// Did the match require stemming? (`run shoes` matched against `running shoes`)
105    pub stemming: bool,
106}
107
108impl PartialOrd for Suggestion {
109    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
110        Some(self.cmp(other))
111    }
112}
113
114impl Ord for Suggestion {
115    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
116        other
117            .score()
118            .partial_cmp(&self.score())
119            .unwrap_or(std::cmp::Ordering::Equal)
120    }
121}
122
123impl Suggestion {
124    /// Get the suggestion's dismissal key, which should be stored in the
125    /// `dismissed_suggestions` table when the suggestion is dismissed. Some
126    /// suggestions may not have dismissal keys and cannot be dismissed.
127    pub fn dismissal_key(&self) -> Option<&str> {
128        match self {
129            Self::Amp { full_keyword, .. } => {
130                if !full_keyword.is_empty() {
131                    Some(full_keyword)
132                } else {
133                    self.raw_url()
134                }
135            }
136            Self::Dynamic { dismissal_key, .. } => dismissal_key.as_deref(),
137            Self::Wikipedia { .. }
138            | Self::Amo { .. }
139            | Self::Yelp { .. }
140            | Self::Mdn { .. }
141            | Self::Weather { .. } => self.raw_url(),
142        }
143    }
144
145    /// Get the URL for this suggestion, if present
146    pub fn url(&self) -> Option<&str> {
147        match self {
148            Self::Amp { url, .. }
149            | Self::Wikipedia { url, .. }
150            | Self::Amo { url, .. }
151            | Self::Yelp { url, .. }
152            | Self::Mdn { url, .. } => Some(url),
153            Self::Weather { .. } | Self::Dynamic { .. } => None,
154        }
155    }
156
157    /// Get the raw URL for this suggestion, if present
158    ///
159    /// This is the same as `url` except for Amp.  In that case, `url` is the URL after being
160    /// "cooked" using template interpolation, while `raw_url` is the URL template.
161    pub fn raw_url(&self) -> Option<&str> {
162        match self {
163            Self::Amp { raw_url, .. } => Some(raw_url),
164            Self::Wikipedia { .. }
165            | Self::Amo { .. }
166            | Self::Yelp { .. }
167            | Self::Mdn { .. }
168            | Self::Weather { .. }
169            | Self::Dynamic { .. } => self.url(),
170        }
171    }
172
173    pub fn title(&self) -> &str {
174        match self {
175            Self::Amp { title, .. }
176            | Self::Wikipedia { title, .. }
177            | Self::Amo { title, .. }
178            | Self::Yelp { title, .. }
179            | Self::Mdn { title, .. } => title,
180            _ => "untitled",
181        }
182    }
183
184    pub fn icon_data(&self) -> Option<&[u8]> {
185        match self {
186            Self::Amp { icon, .. } | Self::Wikipedia { icon, .. } | Self::Yelp { icon, .. } => {
187                icon.as_deref()
188            }
189            _ => None,
190        }
191    }
192
193    pub fn score(&self) -> f64 {
194        match self {
195            Self::Amp { score, .. }
196            | Self::Amo { score, .. }
197            | Self::Yelp { score, .. }
198            | Self::Mdn { score, .. }
199            | Self::Weather { score, .. }
200            | Self::Dynamic { score, .. } => *score,
201            Self::Wikipedia { .. } => DEFAULT_SUGGESTION_SCORE,
202        }
203    }
204
205    pub fn fts_match_info(&self) -> Option<&FtsMatchInfo> {
206        None
207    }
208}
209
210impl Eq for Suggestion {}
211/// Replaces all template parameters in a "raw" sponsored suggestion URL,
212/// producing a "cooked" URL with real values.
213pub(crate) fn cook_raw_suggestion_url(raw_url: &str) -> String {
214    let timestamp = Local::now().format("%Y%m%d%H").to_string();
215    debug_assert!(timestamp.len() == TIMESTAMP_LENGTH);
216    // "Raw" sponsored suggestion URLs must not contain more than one timestamp
217    // template parameter, so we replace just the first occurrence.
218    raw_url.replacen(TIMESTAMP_TEMPLATE, &timestamp, 1)
219}
220
221/// Determines whether a "raw" sponsored suggestion URL is equivalent to a
222/// "cooked" URL. The two URLs are equivalent if they are identical except for
223/// their replaced template parameters, which can be different.
224#[uniffi::export]
225pub fn raw_suggestion_url_matches(raw_url: &str, cooked_url: &str) -> bool {
226    let Some((raw_url_prefix, raw_url_suffix)) = raw_url.split_once(TIMESTAMP_TEMPLATE) else {
227        return raw_url == cooked_url;
228    };
229    let (Some(cooked_url_prefix), Some(cooked_url_suffix)) = (
230        cooked_url.get(..raw_url_prefix.len()),
231        cooked_url.get(raw_url_prefix.len() + TIMESTAMP_LENGTH..),
232    ) else {
233        return false;
234    };
235    if raw_url_prefix != cooked_url_prefix || raw_url_suffix != cooked_url_suffix {
236        return false;
237    }
238    let maybe_timestamp =
239        &cooked_url[raw_url_prefix.len()..raw_url_prefix.len() + TIMESTAMP_LENGTH];
240    maybe_timestamp.bytes().all(|b| b.is_ascii_digit())
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn cook_url_with_template_parameters() {
249        let raw_url_with_one_timestamp = "https://example.com?a=%YYYYMMDDHH%";
250        let cooked_url_with_one_timestamp = cook_raw_suggestion_url(raw_url_with_one_timestamp);
251        assert_eq!(
252            cooked_url_with_one_timestamp.len(),
253            raw_url_with_one_timestamp.len() - 2
254        );
255        assert_ne!(raw_url_with_one_timestamp, cooked_url_with_one_timestamp);
256
257        let raw_url_with_trailing_segment = "https://example.com?a=%YYYYMMDDHH%&b=c";
258        let cooked_url_with_trailing_segment =
259            cook_raw_suggestion_url(raw_url_with_trailing_segment);
260        assert_eq!(
261            cooked_url_with_trailing_segment.len(),
262            raw_url_with_trailing_segment.len() - 2
263        );
264        assert_ne!(
265            raw_url_with_trailing_segment,
266            cooked_url_with_trailing_segment
267        );
268    }
269
270    #[test]
271    fn cook_url_without_template_parameters() {
272        let raw_url_without_timestamp = "https://example.com?b=c";
273        let cooked_url_without_timestamp = cook_raw_suggestion_url(raw_url_without_timestamp);
274        assert_eq!(raw_url_without_timestamp, cooked_url_without_timestamp);
275    }
276
277    #[test]
278    fn url_with_template_parameters_matches() {
279        let raw_url_with_one_timestamp = "https://example.com?a=%YYYYMMDDHH%";
280        let raw_url_with_trailing_segment = "https://example.com?a=%YYYYMMDDHH%&b=c";
281
282        // Equivalent, except for their replaced template parameters.
283        assert!(raw_suggestion_url_matches(
284            raw_url_with_one_timestamp,
285            "https://example.com?a=0000000000"
286        ));
287        assert!(raw_suggestion_url_matches(
288            raw_url_with_trailing_segment,
289            "https://example.com?a=1111111111&b=c"
290        ));
291
292        // Different lengths.
293        assert!(!raw_suggestion_url_matches(
294            raw_url_with_one_timestamp,
295            "https://example.com?a=1234567890&c=d"
296        ));
297        assert!(!raw_suggestion_url_matches(
298            raw_url_with_one_timestamp,
299            "https://example.com?a=123456789"
300        ));
301        assert!(!raw_suggestion_url_matches(
302            raw_url_with_trailing_segment,
303            "https://example.com?a=0987654321"
304        ));
305        assert!(!raw_suggestion_url_matches(
306            raw_url_with_trailing_segment,
307            "https://example.com?a=0987654321&b=c&d=e"
308        ));
309
310        // Different query parameter names.
311        assert!(!raw_suggestion_url_matches(
312            raw_url_with_one_timestamp,         // `a`.
313            "https://example.com?b=4444444444"  // `b`.
314        ));
315        assert!(!raw_suggestion_url_matches(
316            raw_url_with_trailing_segment,          // `a&b`.
317            "https://example.com?a=5555555555&c=c"  // `a&c`.
318        ));
319
320        // Not a timestamp.
321        assert!(!raw_suggestion_url_matches(
322            raw_url_with_one_timestamp,
323            "https://example.com?a=bcdefghijk"
324        ));
325        assert!(!raw_suggestion_url_matches(
326            raw_url_with_trailing_segment,
327            "https://example.com?a=bcdefghijk&b=c"
328        ));
329    }
330
331    #[test]
332    fn url_without_template_parameters_matches() {
333        let raw_url_without_timestamp = "https://example.com?b=c";
334
335        assert!(raw_suggestion_url_matches(
336            raw_url_without_timestamp,
337            "https://example.com?b=c"
338        ));
339        assert!(!raw_suggestion_url_matches(
340            raw_url_without_timestamp,
341            "http://example.com"
342        ));
343        assert!(!raw_suggestion_url_matches(
344            raw_url_without_timestamp, // `a`.
345            "http://example.com?a=c"   // `b`.
346        ));
347        assert!(!raw_suggestion_url_matches(
348            raw_url_without_timestamp,
349            "https://example.com?b=c&d=e"
350        ));
351    }
352}