places/api/
matcher.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
5use crate::db::PlacesDb;
6use crate::error::{warn, Result};
7use crate::ffi::SearchResult as FfiSearchResult;
8pub use crate::match_impl::{MatchBehavior, SearchBehavior};
9use rusqlite::Row;
10use serde_derive::*;
11use sql_support::ConnExt;
12use url::Url;
13
14// A helper to log, cache and execute a query, returning a vector of flattened rows.
15fn query_flat_rows_and_then<T, F, P>(
16    conn: &PlacesDb,
17    sql: &str,
18    params: P,
19    mapper: F,
20) -> Result<Vec<T>>
21where
22    F: FnMut(&Row<'_>) -> Result<T>,
23    P: rusqlite::Params,
24{
25    let mut stmt = conn.prepare_maybe_cached(sql, true)?;
26    let iter = stmt.query_and_then(params, mapper)?;
27    Ok(iter
28        .inspect(|r| {
29            if let Err(ref e) = *r {
30                warn!("Failed to perform a search: {}", e);
31                if cfg!(debug_assertions) {
32                    panic!("Failed to perform a search: {}", e);
33                }
34            }
35        })
36        .flatten()
37        .collect::<Vec<_>>())
38}
39
40#[derive(Debug, Clone)]
41pub struct SearchParams {
42    pub search_string: String,
43    pub limit: u32,
44}
45
46/// Synchronously queries all providers for autocomplete matches, then filters
47/// the matches. This isn't cancelable yet; once a search is started, it can't
48/// be interrupted, even if the user moves on (see
49/// https://github.com/mozilla/application-services/issues/265).
50///
51/// A provider can be anything that returns URL suggestions: Places history
52/// and bookmarks, synced tabs, search engine suggestions, and search keywords.
53pub fn search_frecent(conn: &PlacesDb, params: SearchParams) -> Result<Vec<SearchResult>> {
54    // TODO: Tokenize the query.
55
56    // Try to find the first heuristic result. Desktop tries extensions,
57    // search engine aliases, origins, URLs, search engine domains, and
58    // preloaded sites, before trying to fall back to fixing up the URL,
59    // and a search if all else fails. We only try origins and URLs for
60    // heuristic matches, since that's all we support.
61
62    let mut matches = match_with_limit(
63        conn,
64        &[
65            // Try to match on the origin, or the full URL.
66            &OriginOrUrl::new(&params.search_string),
67            // query adaptive matches and suggestions, matching Anywhere.
68            &Adaptive::with_behavior(
69                &params.search_string,
70                MatchBehavior::Anywhere,
71                SearchBehavior::default(),
72            ),
73            &Suggestions::with_behavior(
74                &params.search_string,
75                MatchBehavior::Anywhere,
76                SearchBehavior::default(),
77            ),
78        ],
79        params.limit,
80    )?;
81
82    matches.sort_unstable_by(|a, b| a.url.cmp(&b.url));
83    matches.dedup_by(|a, b| a.url == b.url);
84
85    Ok(matches)
86}
87
88pub fn match_url(conn: &PlacesDb, query: impl AsRef<str>) -> Result<Option<Url>> {
89    let scope = conn.begin_interrupt_scope()?;
90    let matcher = OriginOrUrl::new(query.as_ref());
91    // Note: The matcher ignores the limit argument (it's a trait method)
92    let results = matcher.search(conn, 1)?;
93    scope.err_if_interrupted()?;
94    // Doing it like this lets us move the result, avoiding a copy (which almost
95    // certainly doesn't matter but whatever)
96    if let Some(res) = results.into_iter().next() {
97        Ok(Some(res.url))
98    } else {
99        Ok(None)
100    }
101}
102
103fn match_with_limit(
104    conn: &PlacesDb,
105    matchers: &[&dyn Matcher],
106    max_results: u32,
107) -> Result<Vec<SearchResult>> {
108    let mut results = Vec::new();
109    let mut rem_results = max_results;
110    let scope = conn.begin_interrupt_scope()?;
111    for m in matchers {
112        if rem_results == 0 {
113            break;
114        }
115        scope.err_if_interrupted()?;
116        let matches = m.search(conn, rem_results)?;
117        results.extend(matches);
118        rem_results = rem_results.saturating_sub(results.len() as u32);
119    }
120    Ok(results)
121}
122
123/// Records an accepted autocomplete match, recording the query string,
124/// and chosen URL for subsequent matches.
125pub fn accept_result(conn: &PlacesDb, search_string: &str, url: &Url) -> Result<()> {
126    // See `nsNavHistory::AutoCompleteFeedback`.
127    conn.execute(
128        "INSERT OR REPLACE INTO moz_inputhistory(place_id, input, use_count)
129         SELECT h.id, IFNULL(i.input, :input_text), IFNULL(i.use_count, 0) * .9 + 1
130         FROM moz_places h
131         LEFT JOIN moz_inputhistory i ON i.place_id = h.id AND i.input = :input_text
132         WHERE url_hash = hash(:page_url) AND url = :page_url",
133        &[
134            (":input_text", &search_string),
135            (":page_url", &url.as_str()),
136        ],
137    )?;
138
139    Ok(())
140}
141
142pub fn split_after_prefix(href: &str) -> (&str, &str) {
143    // Only search up to 64 bytes (matches desktop behavior)
144    let haystack = &href.as_bytes()[..href.len().min(64)];
145    match memchr::memchr(b':', haystack) {
146        None => ("", href),
147        Some(index) => {
148            let hb = href.as_bytes();
149            let mut end = index + 1;
150            if hb.len() >= end + 2 && hb[end] == b'/' && hb[end + 1] == b'/' {
151                end += 2;
152            }
153            href.split_at(end)
154        }
155    }
156}
157
158pub fn split_after_host_and_port(href: &str) -> (&str, &str) {
159    let (_, remainder) = split_after_prefix(href);
160
161    let hp_definite_end =
162        memchr::memchr3(b'/', b'?', b'#', remainder.as_bytes()).unwrap_or(remainder.len());
163
164    let (before_hp, after_hp) = remainder.split_at(hp_definite_end);
165
166    let auth_end = memchr::memchr(b'@', before_hp.as_bytes())
167        .map(|i| i + 1)
168        .unwrap_or(0);
169
170    (&before_hp[auth_end..], after_hp)
171}
172
173fn looks_like_origin(string: &str) -> bool {
174    // Skip nonascii characters, we'll either handle them in autocomplete_match or,
175    // a later part of the origins query.
176    !string.is_empty()
177        && !string.bytes().any(|c| {
178            !c.is_ascii() || c.is_ascii_whitespace() || c == b'/' || c == b'?' || c == b'#'
179        })
180}
181
182#[derive(Debug, Clone, Serialize, Eq, PartialEq)]
183pub struct SearchResult {
184    /// The search string for this match.
185    pub search_string: String,
186
187    /// The URL to open when the user confirms a match. This is
188    /// equivalent to `nsIAutoCompleteResult.getFinalCompleteValueAt`.
189    pub url: Url,
190
191    /// The title of the autocompleted value, to show in the UI. This can be the
192    /// title of the bookmark or page, origin, URL, or URL fragment.
193    pub title: String,
194
195    /// The favicon URL.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub icon_url: Option<Url>,
198
199    /// A frecency score for this match.
200    pub frecency: i64,
201}
202
203impl SearchResult {
204    /// Default search behaviors from Desktop: HISTORY, BOOKMARK, OPENPAGE, SEARCHES.
205    /// Default match behavior: MATCH_BOUNDARY_ANYWHERE.
206    pub fn from_adaptive_row(row: &rusqlite::Row<'_>) -> Result<Self> {
207        let search_string = row.get::<_, String>("searchString")?;
208        let _place_id = row.get::<_, i64>("id")?;
209        let url = row.get::<_, String>("url")?;
210        let history_title = row.get::<_, Option<String>>("title")?;
211        let bookmark_title = row.get::<_, Option<String>>("btitle")?;
212        let frecency = row.get::<_, i64>("frecency")?;
213        let title = bookmark_title.or(history_title).unwrap_or_default();
214        let url = Url::parse(&url)?;
215
216        Ok(Self {
217            search_string,
218            url,
219            title,
220            icon_url: None,
221            frecency,
222        })
223    }
224
225    pub fn from_suggestion_row(row: &rusqlite::Row<'_>) -> Result<Self> {
226        let search_string = row.get::<_, String>("searchString")?;
227        let url = row.get::<_, String>("url")?;
228
229        let history_title = row.get::<_, Option<String>>("title")?;
230        let bookmark_title = row.get::<_, Option<String>>("btitle")?;
231        let title = bookmark_title.or(history_title).unwrap_or_default();
232
233        let url = Url::parse(&url)?;
234
235        let frecency = row.get::<_, i64>("frecency")?;
236
237        Ok(Self {
238            search_string,
239            url,
240            title,
241            icon_url: None,
242            frecency,
243        })
244    }
245
246    pub fn from_origin_row(row: &rusqlite::Row<'_>) -> Result<Self> {
247        let search_string = row.get::<_, String>("searchString")?;
248        let url = row.get::<_, String>("url")?;
249        let display_url = row.get::<_, String>("displayURL")?;
250        let frecency = row.get::<_, i64>("frecency")?;
251
252        let url = Url::parse(&url)?;
253
254        Ok(Self {
255            search_string,
256            url,
257            title: display_url,
258            icon_url: None,
259            frecency,
260        })
261    }
262
263    pub fn from_url_row(row: &rusqlite::Row<'_>) -> Result<Self> {
264        let search_string = row.get::<_, String>("searchString")?;
265        let href = row.get::<_, String>("url")?;
266        let stripped_url = row.get::<_, String>("strippedURL")?;
267        let frecency = row.get::<_, i64>("frecency")?;
268
269        let (url, display_url) = match href.find(&stripped_url) {
270            Some(stripped_url_index) => {
271                let stripped_prefix = &href[..stripped_url_index];
272                let title = match &href[stripped_url_index + stripped_url.len()..].find('/') {
273                    Some(next_slash_index) => {
274                        &href[stripped_url_index
275                            ..=stripped_url_index + stripped_url.len() + next_slash_index]
276                    }
277                    None => &href[stripped_url_index..],
278                };
279                let url = Url::parse(&[stripped_prefix, title].concat())?;
280                (url, title.into())
281            }
282            None => {
283                let url = Url::parse(&href)?;
284                (url, stripped_url)
285            }
286        };
287
288        Ok(Self {
289            search_string,
290            url,
291            title: display_url,
292            icon_url: None,
293            frecency,
294        })
295    }
296}
297
298impl From<SearchResult> for FfiSearchResult {
299    fn from(res: SearchResult) -> Self {
300        Self {
301            url: res.url,
302            title: res.title,
303            frecency: res.frecency,
304        }
305    }
306}
307
308trait Matcher {
309    fn search(&self, conn: &PlacesDb, max_results: u32) -> Result<Vec<SearchResult>>;
310}
311
312struct OriginOrUrl<'query> {
313    query: &'query str,
314}
315
316impl<'query> OriginOrUrl<'query> {
317    pub fn new(query: &'query str) -> OriginOrUrl<'query> {
318        OriginOrUrl { query }
319    }
320}
321
322const URL_SQL: &str = "
323    SELECT h.url as url,
324            :host || :remainder AS strippedURL,
325            h.frecency as frecency,
326            h.foreign_count > 0 AS bookmarked,
327            h.id as id,
328            :searchString AS searchString
329    FROM moz_places h
330    JOIN moz_origins o ON o.id = h.origin_id
331    WHERE o.rev_host = reverse_host(:host)
332            AND MAX(h.frecency, 0) >= :frecencyThreshold
333            AND h.hidden = 0
334            AND strip_prefix_and_userinfo(h.url) BETWEEN strippedURL AND strippedURL || X'FFFF'
335    UNION ALL
336    SELECT h.url as url,
337            :host || :remainder AS strippedURL,
338            h.frecency as frecency,
339            h.foreign_count > 0 AS bookmarked,
340            h.id as id,
341            :searchString AS searchString
342    FROM moz_places h
343    JOIN moz_origins o ON o.id = h.origin_id
344    WHERE o.rev_host = reverse_host(:host) || 'www.'
345            AND MAX(h.frecency, 0) >= :frecencyThreshold
346            AND h.hidden = 0
347            AND strip_prefix_and_userinfo(h.url) BETWEEN 'www.' || strippedURL AND 'www.' || strippedURL || X'FFFF'
348    ORDER BY h.frecency DESC, h.id DESC
349    LIMIT 1
350";
351const ORIGIN_SQL: &str = "
352    SELECT IFNULL(:prefix, prefix) || moz_origins.host || '/' AS url,
353            moz_origins.host || '/' AS displayURL,
354            frecency,
355            bookmarked,
356            id,
357            :searchString AS searchString
358    FROM (
359        SELECT host,
360                TOTAL(frecency) AS host_frecency,
361                (SELECT TOTAL(foreign_count) > 0 FROM moz_places
362                WHERE moz_places.origin_id = moz_origins.id) AS bookmarked
363        FROM moz_origins
364        WHERE host BETWEEN :searchString AND :searchString || X'FFFF'
365        GROUP BY host
366        HAVING host_frecency >= :frecencyThreshold
367        UNION ALL
368        SELECT host,
369                TOTAL(frecency) AS host_frecency,
370                (SELECT TOTAL(foreign_count) > 0 FROM moz_places
371                WHERE moz_places.origin_id = moz_origins.id) AS bookmarked
372        FROM moz_origins
373        WHERE host BETWEEN 'www.' || :searchString AND 'www.' || :searchString || X'FFFF'
374        GROUP BY host
375        HAVING host_frecency >= :frecencyThreshold
376    ) AS grouped_hosts
377    JOIN moz_origins ON moz_origins.host = grouped_hosts.host
378    ORDER BY frecency DESC, id DESC
379    LIMIT 1
380";
381
382impl Matcher for OriginOrUrl<'_> {
383    fn search(&self, conn: &PlacesDb, _: u32) -> Result<Vec<SearchResult>> {
384        Ok(if looks_like_origin(self.query) {
385            query_flat_rows_and_then(
386                conn,
387                ORIGIN_SQL,
388                &[
389                    (":prefix", &rusqlite::types::Null as &dyn rusqlite::ToSql),
390                    (":searchString", &self.query),
391                    (":frecencyThreshold", &-1i64),
392                ],
393                SearchResult::from_origin_row,
394            )?
395        } else if self.query.contains(['/', ':', '?']) {
396            let (host, remainder) = split_after_host_and_port(self.query);
397            // This can fail if the "host" has some characters that are not
398            // currently allowed in URLs (even when punycoded). If that happens,
399            // then the query we'll use here can't return any results (and
400            // indeed, `reverse_host` will get mad at us since it's an invalid
401            // host), so we just return an empty results set.
402            let punycode_host = idna::domain_to_ascii(host);
403            let host_str = if let Ok(host) = &punycode_host {
404                host.as_str()
405            } else {
406                return Ok(vec![]);
407            };
408            query_flat_rows_and_then(
409                conn,
410                URL_SQL,
411                &[
412                    (":searchString", &self.query as &dyn rusqlite::ToSql),
413                    (":host", &host_str),
414                    (":remainder", &remainder),
415                    (":frecencyThreshold", &-1i64),
416                ],
417                SearchResult::from_url_row,
418            )?
419        } else {
420            vec![]
421        })
422    }
423}
424
425struct Adaptive<'query> {
426    query: &'query str,
427    match_behavior: MatchBehavior,
428    search_behavior: SearchBehavior,
429}
430
431impl<'query> Adaptive<'query> {
432    pub fn with_behavior(
433        query: &'query str,
434        match_behavior: MatchBehavior,
435        search_behavior: SearchBehavior,
436    ) -> Adaptive<'query> {
437        Adaptive {
438            query,
439            match_behavior,
440            search_behavior,
441        }
442    }
443}
444
445impl Matcher for Adaptive<'_> {
446    fn search(&self, conn: &PlacesDb, max_results: u32) -> Result<Vec<SearchResult>> {
447        query_flat_rows_and_then(
448            conn,
449            "
450            SELECT h.url as url,
451                   h.title as title,
452                   EXISTS(SELECT 1 FROM moz_bookmarks
453                          WHERE fk = h.id) AS bookmarked,
454                   (SELECT title FROM moz_bookmarks
455                    WHERE fk = h.id AND
456                          title NOT NULL
457                    ORDER BY lastModified DESC
458                    LIMIT 1) AS btitle,
459                   NULL AS tags,
460                   h.visit_count_local + h.visit_count_remote AS visit_count,
461                   h.typed as typed,
462                   h.id as id,
463                   NULL AS open_count,
464                   h.frecency as frecency,
465                   :searchString AS searchString
466            FROM (
467              SELECT ROUND(MAX(use_count) * (1 + (input = :searchString)), 1) AS rank,
468                     place_id
469              FROM moz_inputhistory
470              WHERE input BETWEEN :searchString AND :searchString || X'FFFF'
471              GROUP BY place_id
472            ) AS i
473            JOIN moz_places h ON h.id = i.place_id
474            WHERE AUTOCOMPLETE_MATCH(:searchString, h.url,
475                                     IFNULL(btitle, h.title), tags,
476                                     visit_count, h.typed, bookmarked,
477                                     NULL, :matchBehavior, :searchBehavior)
478            ORDER BY rank DESC, h.frecency DESC
479            LIMIT :maxResults",
480            &[
481                (":searchString", &self.query as &dyn rusqlite::ToSql),
482                (":matchBehavior", &self.match_behavior),
483                (":searchBehavior", &self.search_behavior),
484                (":maxResults", &max_results),
485            ],
486            SearchResult::from_adaptive_row,
487        )
488    }
489}
490
491struct Suggestions<'query> {
492    query: &'query str,
493    match_behavior: MatchBehavior,
494    search_behavior: SearchBehavior,
495}
496
497impl<'query> Suggestions<'query> {
498    pub fn with_behavior(
499        query: &'query str,
500        match_behavior: MatchBehavior,
501        search_behavior: SearchBehavior,
502    ) -> Suggestions<'query> {
503        Suggestions {
504            query,
505            match_behavior,
506            search_behavior,
507        }
508    }
509}
510
511impl Matcher for Suggestions<'_> {
512    fn search(&self, conn: &PlacesDb, max_results: u32) -> Result<Vec<SearchResult>> {
513        query_flat_rows_and_then(
514            conn,
515            "
516            SELECT h.url, h.title,
517                   EXISTS(SELECT 1 FROM moz_bookmarks
518                          WHERE fk = h.id) AS bookmarked,
519                   (SELECT title FROM moz_bookmarks
520                    WHERE fk = h.id AND
521                          title NOT NULL
522                    ORDER BY lastModified DESC
523                    LIMIT 1) AS btitle,
524                   NULL AS tags,
525                   h.visit_count_local + h.visit_count_remote AS visit_count,
526                   h.typed as typed,
527                   h.id as id,
528                   NULL AS open_count, h.frecency, :searchString AS searchString
529            FROM moz_places h
530            WHERE h.frecency > 0
531              AND AUTOCOMPLETE_MATCH(:searchString, h.url,
532                                     IFNULL(btitle, h.title), tags,
533                                     visit_count, h.typed,
534                                     bookmarked, NULL,
535                                     :matchBehavior, :searchBehavior)
536              AND (+h.visit_count_local > 0 OR +h.visit_count_remote > 0)
537            ORDER BY h.frecency DESC, h.id DESC
538            LIMIT :maxResults",
539            &[
540                (":searchString", &self.query as &dyn rusqlite::ToSql),
541                (":matchBehavior", &self.match_behavior),
542                (":searchBehavior", &self.search_behavior),
543                (":maxResults", &max_results),
544            ],
545            SearchResult::from_suggestion_row,
546        )
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use crate::api::places_api::test::new_mem_connection;
554    use crate::observation::VisitObservation;
555    use crate::storage::history::apply_observation;
556    use crate::types::VisitType;
557    use types::Timestamp;
558
559    #[test]
560    fn split() {
561        assert_eq!(
562            split_after_prefix("http://example.com"),
563            ("http://", "example.com")
564        );
565        assert_eq!(split_after_prefix("foo:example"), ("foo:", "example"));
566        assert_eq!(split_after_prefix("foo:"), ("foo:", ""));
567        assert_eq!(split_after_prefix("notaspec"), ("", "notaspec"));
568        assert_eq!(split_after_prefix("http:/"), ("http:", "/"));
569        assert_eq!(split_after_prefix("http://"), ("http://", ""));
570
571        assert_eq!(
572            split_after_host_and_port("http://example.com/"),
573            ("example.com", "/")
574        );
575        assert_eq!(
576            split_after_host_and_port("http://example.com:8888/"),
577            ("example.com:8888", "/")
578        );
579        assert_eq!(
580            split_after_host_and_port("http://user:pass@example.com/"),
581            ("example.com", "/")
582        );
583        assert_eq!(split_after_host_and_port("foo:example"), ("example", ""));
584
585        assert_eq!(
586            split_after_host_and_port("http://foo.com/stuff/@21.3132115"),
587            ("foo.com", "/stuff/@21.3132115")
588        );
589        assert_eq!(
590            split_after_host_and_port("http://foo.com/go?email=foo@example.com"),
591            ("foo.com", "/go?email=foo@example.com")
592        );
593
594        assert_eq!(
595            split_after_host_and_port("http://a:b@foo.com/stuff/@21.3132115"),
596            ("foo.com", "/stuff/@21.3132115")
597        );
598        assert_eq!(
599            split_after_host_and_port("http://a:b@foo.com/123#abcdef@title"),
600            ("foo.com", "/123#abcdef@title")
601        );
602    }
603
604    #[test]
605    fn search() {
606        let conn = new_mem_connection();
607
608        let url = Url::parse("http://example.com/123").unwrap();
609        let visit = VisitObservation::new(url.clone())
610            .with_title("Example page 123".to_string())
611            .with_visit_type(VisitType::Typed)
612            .with_at(Timestamp::now());
613
614        apply_observation(&conn, visit).expect("Should apply visit");
615
616        let by_origin = search_frecent(
617            &conn,
618            SearchParams {
619                search_string: "example.com".into(),
620                limit: 10,
621            },
622        )
623        .expect("Should search by origin");
624        assert!(by_origin
625            .iter()
626            .any(|result| result.search_string == "example.com"
627                && result.title == "example.com/"
628                && result.url.as_str() == "http://example.com/"));
629
630        let by_url_without_path = search_frecent(
631            &conn,
632            SearchParams {
633                search_string: "http://example.com".into(),
634                limit: 10,
635            },
636        )
637        .expect("Should search by URL without path");
638        assert!(by_url_without_path
639            .iter()
640            .any(|result| result.title == "example.com/"
641                && result.url.as_str() == "http://example.com/"));
642
643        let by_url_with_path = search_frecent(
644            &conn,
645            SearchParams {
646                search_string: "http://example.com/1".into(),
647                limit: 10,
648            },
649        )
650        .expect("Should search by URL with path");
651        assert!(by_url_with_path
652            .iter()
653            .any(|result| result.title == "example.com/123"
654                && result.url.as_str() == "http://example.com/123"));
655
656        accept_result(&conn, "ample", &url).expect("Should accept input history match");
657
658        let by_adaptive = search_frecent(
659            &conn,
660            SearchParams {
661                search_string: "ample".into(),
662                limit: 10,
663            },
664        )
665        .expect("Should search by adaptive input history");
666        assert!(by_adaptive
667            .iter()
668            .any(|result| result.search_string == "ample" && result.url == url));
669
670        let with_limit = search_frecent(
671            &conn,
672            SearchParams {
673                search_string: "example".into(),
674                limit: 1,
675            },
676        )
677        .expect("Should search until reaching limit");
678        assert_eq!(
679            with_limit,
680            vec![SearchResult {
681                search_string: "example".into(),
682                url: Url::parse("http://example.com/").unwrap(),
683                title: "example.com/".into(),
684                icon_url: None,
685                frecency: 2000,
686            }]
687        );
688    }
689    #[test]
690    fn search_unicode() {
691        let conn = new_mem_connection();
692
693        let url = Url::parse("http://exämple.com/123").unwrap();
694        let visit = VisitObservation::new(url)
695            .with_title("Example page 123".to_string())
696            .with_visit_type(VisitType::Typed)
697            .with_at(Timestamp::now());
698
699        apply_observation(&conn, visit).expect("Should apply visit");
700
701        let by_url_without_path = search_frecent(
702            &conn,
703            SearchParams {
704                search_string: "http://exämple.com".into(),
705                limit: 10,
706            },
707        )
708        .expect("Should search by URL without path");
709        assert!(by_url_without_path
710            .iter()
711            // Should we consider un-punycoding the title? (firefox desktop doesn't...)
712            .any(|result| result.title == "xn--exmple-cua.com/"
713                && result.url.as_str() == "http://xn--exmple-cua.com/"));
714
715        let by_url_with_path = search_frecent(
716            &conn,
717            SearchParams {
718                search_string: "http://exämple.com/1".into(),
719                limit: 10,
720            },
721        )
722        .expect("Should search by URL with path");
723        assert!(
724            by_url_with_path
725                .iter()
726                .any(|result| result.title == "xn--exmple-cua.com/123"
727                    && result.url.as_str() == "http://xn--exmple-cua.com/123"),
728            "{:?}",
729            by_url_with_path
730        );
731
732        // The "ball of yarn" emoji is not currently accepted as valid
733        // in URLs, but we should just return an empty result set.
734        let ball_of_yarn_about_blank = "about:blank🧶";
735        let empty = match_url(&conn, ball_of_yarn_about_blank).unwrap();
736        assert!(empty.is_none());
737        // Just run this to make sure the unwrap doesn't panic us
738        search_frecent(
739            &conn,
740            SearchParams {
741                search_string: ball_of_yarn_about_blank.into(),
742                limit: 10,
743            },
744        )
745        .unwrap();
746    }
747    // This panics in tests but not for "real" consumers. In an effort to ensure
748    // we are panicking where we think we are, note the 'expected' string.
749    // (Not really clear this test offers much value, but seems worth having...)
750    #[test]
751    #[cfg_attr(
752        debug_assertions,
753        should_panic(expected = "Failed to perform a search:")
754    )]
755    fn search_invalid_url() {
756        let conn = new_mem_connection();
757
758        conn.execute(
759            "INSERT INTO moz_places (guid, url, url_hash, frecency)
760             VALUES ('fake_guid___', 'not-a-url', hash('not-a-url'), 10)",
761            [],
762        )
763        .expect("should insert");
764        crate::storage::delete_pending_temp_tables(&conn).expect("should work");
765
766        let _ = search_frecent(
767            &conn,
768            SearchParams {
769                search_string: "not-a-url".into(),
770                limit: 10,
771            },
772        );
773    }
774}