Skip to main content

doing_ops/
search.rs

1use std::borrow::Cow;
2
3use doing_config::SearchConfig;
4use doing_taskpaper::Entry;
5use regex::Regex;
6use sublime_fuzzy::best_match;
7
8/// How text comparisons handle letter case.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum CaseSensitivity {
11  Ignore,
12  Sensitive,
13}
14
15/// A single token inside a [`SearchMode::Pattern`] query.
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub enum PatternToken {
18  /// Token must NOT appear in the text.
19  Exclude(String),
20  /// Token must appear in the text.
21  Include(String),
22  /// Quoted phrase that must appear as-is.
23  Phrase(String),
24}
25
26/// The kind of text matching to apply.
27#[derive(Clone, Debug)]
28pub enum SearchMode {
29  /// Exact literal substring match (triggered by `'` prefix).
30  Exact(String),
31  /// Fuzzy character-order match with a maximum gap distance.
32  Fuzzy(String, u32),
33  /// Space-separated tokens with `+require`, `-exclude`, and `"quoted phrase"` support.
34  Pattern(Vec<PatternToken>),
35  /// Full regular expression (triggered by `/pattern/` syntax).
36  Regex(Regex),
37}
38
39/// Test whether `text` matches the given search mode and case sensitivity.
40pub fn matches(text: &str, mode: &SearchMode, case: CaseSensitivity) -> bool {
41  match mode {
42    SearchMode::Exact(literal) => matches_exact(text, literal, case),
43    SearchMode::Fuzzy(pattern, distance) => matches_fuzzy(text, pattern, *distance, case),
44    SearchMode::Pattern(tokens) => matches_pattern(text, tokens, case),
45    SearchMode::Regex(rx) => rx.is_match(text),
46  }
47}
48
49/// Test whether an entry matches the given search mode and case sensitivity.
50///
51/// Searches the entry title, tag names, and optionally the note lines when
52/// `include_notes` is `true`. Returns `true` if any of these match.
53pub fn matches_entry(entry: &Entry, mode: &SearchMode, case: CaseSensitivity, include_notes: bool) -> bool {
54  if matches(entry.title(), mode, case) {
55    return true;
56  }
57
58  for tag in entry.tags().iter() {
59    if matches(tag.name(), mode, case) {
60      return true;
61    }
62  }
63
64  if include_notes && !entry.note().is_empty() {
65    let note_text = entry.note().lines().join(" ");
66    if matches(&note_text, mode, case) {
67      return true;
68    }
69  }
70
71  false
72}
73
74/// Build a [`SearchMode`] and [`CaseSensitivity`] from a raw query string and config.
75pub fn parse_query(query: &str, config: &SearchConfig) -> Option<(SearchMode, CaseSensitivity)> {
76  let query = query.trim();
77  if query.is_empty() {
78    return None;
79  }
80
81  let case = resolve_case(query, config);
82  let mode = detect_mode(query, config, case);
83
84  Some((mode, case))
85}
86
87/// Build a compiled regex, applying case-insensitivity flag when needed.
88fn build_regex(pattern: &str, original_query: &str, config: &SearchConfig) -> Result<Regex, regex::Error> {
89  let case = resolve_case(original_query, config);
90  let full_pattern = match case {
91    CaseSensitivity::Ignore => format!("(?i){pattern}"),
92    CaseSensitivity::Sensitive => pattern.to_string(),
93  };
94  Regex::new(&full_pattern)
95}
96
97/// Detect the search mode from the query string and config.
98///
99/// Detection order:
100/// 1. `'` prefix → exact mode
101/// 2. `/pattern/` → regex mode
102/// 3. Config `matching` == `fuzzy` → fuzzy mode
103/// 4. Otherwise → pattern mode
104fn detect_mode(query: &str, config: &SearchConfig, case: CaseSensitivity) -> SearchMode {
105  if let Some(literal) = query.strip_prefix('\'') {
106    return SearchMode::Exact(maybe_lowercase(literal, case));
107  }
108
109  if let Some(inner) = try_extract_regex(query)
110    && let Ok(rx) = build_regex(&inner, query, config)
111  {
112    return SearchMode::Regex(rx);
113  }
114
115  if config.matching == "fuzzy" {
116    return SearchMode::Fuzzy(maybe_lowercase(query, case), config.distance);
117  }
118
119  SearchMode::Pattern(parse_pattern_tokens(query, case))
120}
121
122/// Check whether `text` contains the exact literal substring.
123///
124/// The `literal` is expected to be pre-lowercased when `case` is `Ignore`.
125fn matches_exact(text: &str, literal: &str, case: CaseSensitivity) -> bool {
126  match case {
127    CaseSensitivity::Sensitive => text.contains(literal),
128    CaseSensitivity::Ignore => text.to_lowercase().contains(literal),
129  }
130}
131
132/// Check whether `text` matches a fuzzy pattern using `sublime_fuzzy`.
133///
134/// Characters in `pattern` must appear in `text` in order, but gaps are allowed.
135/// The `distance` parameter sets the maximum allowed gap between consecutive
136/// matched characters. A distance of 0 disables the gap check.
137///
138/// The `pattern` is expected to be pre-lowercased when `case` is `Ignore`.
139fn matches_fuzzy(text: &str, pattern: &str, distance: u32, case: CaseSensitivity) -> bool {
140  let haystack: Cow<str> = match case {
141    CaseSensitivity::Sensitive => Cow::Borrowed(text),
142    CaseSensitivity::Ignore => Cow::Owned(text.to_lowercase()),
143  };
144
145  let result = match best_match(pattern, &haystack) {
146    Some(m) => m,
147    None => return false,
148  };
149
150  if distance == 0 {
151    return true;
152  }
153
154  let positions: Vec<usize> = result
155    .continuous_matches()
156    .flat_map(|cm| cm.start()..cm.start() + cm.len())
157    .collect();
158  positions.windows(2).all(|w| (w[1] - w[0] - 1) as u32 <= distance)
159}
160
161/// Check whether `text` matches all pattern tokens.
162///
163/// - Include: word must appear anywhere in text.
164/// - Exclude: word must NOT appear in text.
165/// - Phrase: exact substring must appear in text.
166///
167/// Tokens are expected to be pre-lowercased when `case` is `Ignore`.
168fn matches_pattern(text: &str, tokens: &[PatternToken], case: CaseSensitivity) -> bool {
169  let lowered;
170  let haystack = match case {
171    CaseSensitivity::Ignore => {
172      lowered = text.to_lowercase();
173      &lowered
174    }
175    CaseSensitivity::Sensitive => text,
176  };
177
178  for token in tokens {
179    let needle = match token {
180      PatternToken::Exclude(word) | PatternToken::Include(word) | PatternToken::Phrase(word) => word.as_str(),
181    };
182    let found = haystack.contains(needle);
183    match token {
184      PatternToken::Exclude(_) if found => return false,
185      PatternToken::Include(_) | PatternToken::Phrase(_) if !found => return false,
186      _ => {}
187    }
188  }
189  true
190}
191
192/// Parse a pattern-mode query into tokens.
193///
194/// Supports:
195/// - `"quoted phrase"` → Phrase token
196/// - `+word` → Include token (required)
197/// - `-word` → Exclude token (excluded)
198/// - bare `word` → Include token
199fn maybe_lowercase(s: &str, case: CaseSensitivity) -> String {
200  match case {
201    CaseSensitivity::Ignore => s.to_lowercase(),
202    CaseSensitivity::Sensitive => s.to_string(),
203  }
204}
205
206fn parse_pattern_tokens(query: &str, case: CaseSensitivity) -> Vec<PatternToken> {
207  let mut tokens = Vec::new();
208  let mut chars = query.chars().peekable();
209
210  while let Some(&c) = chars.peek() {
211    if c.is_whitespace() {
212      chars.next();
213      continue;
214    }
215
216    if c == '"' {
217      chars.next(); // consume opening quote
218      let phrase: String = chars.by_ref().take_while(|&ch| ch != '"').collect();
219      if !phrase.is_empty() {
220        tokens.push(PatternToken::Phrase(maybe_lowercase(&phrase, case)));
221      }
222    } else if c == '+' {
223      chars.next(); // consume +
224      let word: String = chars.by_ref().take_while(|ch| !ch.is_whitespace()).collect();
225      if !word.is_empty() {
226        tokens.push(PatternToken::Include(maybe_lowercase(&word, case)));
227      }
228    } else if c == '-' {
229      chars.next(); // consume -
230      let word: String = chars.by_ref().take_while(|ch| !ch.is_whitespace()).collect();
231      if !word.is_empty() {
232        tokens.push(PatternToken::Exclude(maybe_lowercase(&word, case)));
233      }
234    } else {
235      let word: String = chars.by_ref().take_while(|ch| !ch.is_whitespace()).collect();
236      if !word.is_empty() {
237        tokens.push(PatternToken::Include(maybe_lowercase(&word, case)));
238      }
239    }
240  }
241
242  tokens
243}
244
245/// Determine case sensitivity from the query and config.
246///
247/// Smart mode: all-lowercase query → case-insensitive; any uppercase → case-sensitive.
248/// The `search.case` config can override to `sensitive` or `ignore`.
249fn resolve_case(query: &str, config: &SearchConfig) -> CaseSensitivity {
250  match config.case.as_str() {
251    "sensitive" => CaseSensitivity::Sensitive,
252    "ignore" => CaseSensitivity::Ignore,
253    _ => {
254      // smart: any uppercase character triggers case-sensitive
255      if query.chars().any(|c| c.is_uppercase()) {
256        CaseSensitivity::Sensitive
257      } else {
258        CaseSensitivity::Ignore
259      }
260    }
261  }
262}
263
264/// Try to extract a regex pattern from `/pattern/` syntax.
265fn try_extract_regex(query: &str) -> Option<String> {
266  let rest = query.strip_prefix('/')?;
267  let inner = rest.strip_suffix('/')?;
268  if inner.is_empty() {
269    return None;
270  }
271  Some(inner.to_string())
272}
273
274#[cfg(test)]
275mod test {
276  use super::*;
277
278  fn contains_word(text: &str, word: &str, case: CaseSensitivity) -> bool {
279    match case {
280      CaseSensitivity::Sensitive => text.contains(word),
281      CaseSensitivity::Ignore => text.to_lowercase().contains(&word.to_lowercase()),
282    }
283  }
284
285  fn default_config() -> SearchConfig {
286    SearchConfig::default()
287  }
288
289  fn fuzzy_config() -> SearchConfig {
290    SearchConfig {
291      matching: "fuzzy".into(),
292      ..SearchConfig::default()
293    }
294  }
295
296  mod contains_word {
297    use super::*;
298
299    #[test]
300    fn it_finds_case_insensitive_match() {
301      assert!(super::contains_word("Hello World", "hello", CaseSensitivity::Ignore));
302    }
303
304    #[test]
305    fn it_finds_case_sensitive_match() {
306      assert!(super::contains_word("Hello World", "Hello", CaseSensitivity::Sensitive));
307    }
308
309    #[test]
310    fn it_rejects_case_mismatch_when_sensitive() {
311      assert!(!super::contains_word(
312        "Hello World",
313        "hello",
314        CaseSensitivity::Sensitive
315      ));
316    }
317  }
318
319  mod detect_mode {
320    use super::*;
321
322    #[test]
323    fn it_detects_exact_mode_with_quote_prefix() {
324      let mode = super::super::detect_mode("'exact match", &default_config(), CaseSensitivity::Ignore);
325
326      assert!(matches!(mode, SearchMode::Exact(s) if s == "exact match"));
327    }
328
329    #[test]
330    fn it_detects_fuzzy_mode_from_config() {
331      let mode = super::super::detect_mode("some query", &fuzzy_config(), CaseSensitivity::Ignore);
332
333      assert!(matches!(mode, SearchMode::Fuzzy(s, 3) if s == "some query"));
334    }
335
336    #[test]
337    fn it_detects_pattern_mode_by_default() {
338      let mode = super::super::detect_mode("hello world", &default_config(), CaseSensitivity::Ignore);
339
340      assert!(matches!(mode, SearchMode::Pattern(_)));
341    }
342
343    #[test]
344    fn it_detects_regex_mode_with_slashes() {
345      let mode = super::super::detect_mode("/foo.*bar/", &default_config(), CaseSensitivity::Ignore);
346
347      assert!(matches!(mode, SearchMode::Regex(_)));
348    }
349  }
350
351  mod matches_entry {
352    use chrono::{Local, TimeZone};
353    use doing_taskpaper::{Note, Tag, Tags};
354
355    use super::*;
356
357    fn sample_entry() -> Entry {
358      Entry::new(
359        Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
360        "Working on search feature",
361        Tags::new(),
362        Note::from_text("Added fuzzy matching\nFixed regex parsing"),
363        "Currently",
364        None::<String>,
365      )
366    }
367
368    fn tagged_entry() -> Entry {
369      Entry::new(
370        Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
371        "Working on project",
372        Tags::from_iter(vec![
373          Tag::new("coding", None::<String>),
374          Tag::new("rust", None::<String>),
375        ]),
376        Note::new(),
377        "Currently",
378        None::<String>,
379      )
380    }
381
382    #[test]
383    fn it_does_not_duplicate_results_for_title_and_tag_match() {
384      let entry = Entry::new(
385        Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
386        "coding session",
387        Tags::from_iter(vec![Tag::new("coding", None::<String>)]),
388        Note::new(),
389        "Currently",
390        None::<String>,
391      );
392      let mode = SearchMode::Pattern(vec![PatternToken::Include("coding".into())]);
393
394      assert!(super::super::matches_entry(
395        &entry,
396        &mode,
397        CaseSensitivity::Ignore,
398        false,
399      ));
400    }
401
402    #[test]
403    fn it_matches_note_when_include_notes_enabled() {
404      let mode = SearchMode::Pattern(vec![PatternToken::Include("fuzzy".into())]);
405
406      assert!(super::super::matches_entry(
407        &sample_entry(),
408        &mode,
409        CaseSensitivity::Ignore,
410        true,
411      ));
412    }
413
414    #[test]
415    fn it_does_not_match_across_tag_boundaries() {
416      // Tags ["co", "ding"] should not match "co ding" since they are separate tags.
417      let entry = Entry::new(
418        Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
419        "Some task",
420        Tags::from_iter(vec![Tag::new("co", None::<String>), Tag::new("ding", None::<String>)]),
421        Note::new(),
422        "Currently",
423        None::<String>,
424      );
425      let mode = SearchMode::Pattern(vec![PatternToken::Include("co ding".into())]);
426
427      assert!(!super::super::matches_entry(
428        &entry,
429        &mode,
430        CaseSensitivity::Ignore,
431        false
432      ));
433    }
434
435    #[test]
436    fn it_does_not_match_tag_spanning_two_tags() {
437      // Searching for "coding" should not match tags ["co", "ding"]
438      let entry = Entry::new(
439        Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
440        "Some task",
441        Tags::from_iter(vec![Tag::new("co", None::<String>), Tag::new("ding", None::<String>)]),
442        Note::new(),
443        "Currently",
444        None::<String>,
445      );
446      let mode = SearchMode::Pattern(vec![PatternToken::Include("coding".into())]);
447
448      assert!(!super::super::matches_entry(
449        &entry,
450        &mode,
451        CaseSensitivity::Ignore,
452        false
453      ));
454    }
455
456    #[test]
457    fn it_matches_tag_name() {
458      let mode = SearchMode::Pattern(vec![PatternToken::Include("coding".into())]);
459
460      assert!(super::super::matches_entry(
461        &tagged_entry(),
462        &mode,
463        CaseSensitivity::Ignore,
464        false,
465      ));
466    }
467
468    #[test]
469    fn it_matches_tag_name_without_at_prefix() {
470      let mode = SearchMode::Pattern(vec![PatternToken::Include("rust".into())]);
471
472      assert!(super::super::matches_entry(
473        &tagged_entry(),
474        &mode,
475        CaseSensitivity::Ignore,
476        false,
477      ));
478    }
479
480    #[test]
481    fn it_matches_title() {
482      let mode = SearchMode::Pattern(vec![PatternToken::Include("search".into())]);
483
484      assert!(super::super::matches_entry(
485        &sample_entry(),
486        &mode,
487        CaseSensitivity::Ignore,
488        false,
489      ));
490    }
491
492    #[test]
493    fn it_returns_false_when_nothing_matches() {
494      let mode = SearchMode::Pattern(vec![PatternToken::Include("nonexistent".into())]);
495
496      assert!(!super::super::matches_entry(
497        &sample_entry(),
498        &mode,
499        CaseSensitivity::Ignore,
500        true,
501      ));
502    }
503
504    #[test]
505    fn it_skips_note_when_include_notes_disabled() {
506      let mode = SearchMode::Pattern(vec![PatternToken::Include("fuzzy".into())]);
507
508      assert!(!super::super::matches_entry(
509        &sample_entry(),
510        &mode,
511        CaseSensitivity::Ignore,
512        false,
513      ));
514    }
515  }
516
517  mod matches_exact {
518    use super::*;
519
520    #[test]
521    fn it_matches_case_insensitive_substring() {
522      assert!(super::super::matches_exact(
523        "Working on Project",
524        "on project",
525        CaseSensitivity::Ignore,
526      ));
527    }
528
529    #[test]
530    fn it_matches_case_sensitive_substring() {
531      assert!(super::super::matches_exact(
532        "Working on Project",
533        "on Project",
534        CaseSensitivity::Sensitive,
535      ));
536    }
537
538    #[test]
539    fn it_rejects_missing_substring() {
540      assert!(!super::super::matches_exact(
541        "Working on Project",
542        "missing",
543        CaseSensitivity::Ignore,
544      ));
545    }
546  }
547
548  mod matches_fuzzy {
549    use super::*;
550
551    #[test]
552    fn it_matches_characters_in_order_with_gaps() {
553      assert!(super::super::matches_fuzzy(
554        "Working on project",
555        "wop",
556        0,
557        CaseSensitivity::Ignore
558      ));
559    }
560
561    #[test]
562    fn it_matches_when_gap_within_distance() {
563      assert!(super::super::matches_fuzzy("a__b", "ab", 3, CaseSensitivity::Sensitive));
564    }
565
566    #[test]
567    fn it_rejects_characters_out_of_order() {
568      assert!(!super::super::matches_fuzzy(
569        "abc",
570        "cab",
571        0,
572        CaseSensitivity::Sensitive
573      ));
574    }
575
576    #[test]
577    fn it_rejects_when_gap_exceeds_distance() {
578      assert!(!super::super::matches_fuzzy(
579        "a____b",
580        "ab",
581        2,
582        CaseSensitivity::Sensitive
583      ));
584    }
585
586    #[test]
587    fn it_skips_distance_check_when_zero() {
588      assert!(super::super::matches_fuzzy(
589        "a______________b",
590        "ab",
591        0,
592        CaseSensitivity::Sensitive
593      ));
594    }
595  }
596
597  mod matches_pattern {
598    use super::*;
599
600    #[test]
601    fn it_matches_all_include_tokens() {
602      let tokens = vec![
603        PatternToken::Include("hello".into()),
604        PatternToken::Include("world".into()),
605      ];
606
607      assert!(super::super::matches_pattern(
608        "hello beautiful world",
609        &tokens,
610        CaseSensitivity::Ignore,
611      ));
612    }
613
614    #[test]
615    fn it_matches_quoted_phrase() {
616      let tokens = vec![PatternToken::Phrase("hello world".into())];
617
618      assert!(super::super::matches_pattern(
619        "say hello world today",
620        &tokens,
621        CaseSensitivity::Ignore,
622      ));
623    }
624
625    #[test]
626    fn it_rejects_when_exclude_token_found() {
627      let tokens = vec![
628        PatternToken::Include("hello".into()),
629        PatternToken::Exclude("world".into()),
630      ];
631
632      assert!(!super::super::matches_pattern(
633        "hello world",
634        &tokens,
635        CaseSensitivity::Ignore,
636      ));
637    }
638
639    #[test]
640    fn it_rejects_when_include_token_missing() {
641      let tokens = vec![PatternToken::Include("missing".into())];
642
643      assert!(!super::super::matches_pattern(
644        "hello world",
645        &tokens,
646        CaseSensitivity::Ignore,
647      ));
648    }
649  }
650
651  mod parse_pattern_tokens {
652    use pretty_assertions::assert_eq;
653
654    use super::*;
655
656    fn parse_pattern_tokens_sensitive(query: &str) -> Vec<PatternToken> {
657      super::super::parse_pattern_tokens(query, CaseSensitivity::Sensitive)
658    }
659
660    #[test]
661    fn it_parses_bare_words_as_include() {
662      let tokens = parse_pattern_tokens_sensitive("hello world");
663
664      assert_eq!(
665        tokens,
666        vec![
667          PatternToken::Include("hello".into()),
668          PatternToken::Include("world".into()),
669        ]
670      );
671    }
672
673    #[test]
674    fn it_parses_exclude_tokens() {
675      let tokens = parse_pattern_tokens_sensitive("hello -world");
676
677      assert_eq!(
678        tokens,
679        vec![
680          PatternToken::Include("hello".into()),
681          PatternToken::Exclude("world".into()),
682        ]
683      );
684    }
685
686    #[test]
687    fn it_parses_include_tokens() {
688      let tokens = parse_pattern_tokens_sensitive("+hello +world");
689
690      assert_eq!(
691        tokens,
692        vec![
693          PatternToken::Include("hello".into()),
694          PatternToken::Include("world".into()),
695        ]
696      );
697    }
698
699    #[test]
700    fn it_parses_mixed_tokens() {
701      let tokens = parse_pattern_tokens_sensitive("+required -excluded bare \"exact phrase\"");
702
703      assert_eq!(
704        tokens,
705        vec![
706          PatternToken::Include("required".into()),
707          PatternToken::Exclude("excluded".into()),
708          PatternToken::Include("bare".into()),
709          PatternToken::Phrase("exact phrase".into()),
710        ]
711      );
712    }
713
714    #[test]
715    fn it_parses_quoted_phrases() {
716      let tokens = parse_pattern_tokens_sensitive("\"hello world\"");
717
718      assert_eq!(tokens, vec![PatternToken::Phrase("hello world".into())]);
719    }
720  }
721
722  mod parse_query {
723    use super::*;
724
725    #[test]
726    fn it_returns_none_for_empty_query() {
727      assert!(super::super::parse_query("", &default_config()).is_none());
728    }
729
730    #[test]
731    fn it_returns_none_for_whitespace_query() {
732      assert!(super::super::parse_query("   ", &default_config()).is_none());
733    }
734
735    #[test]
736    fn it_returns_pattern_mode_by_default() {
737      let (mode, _) = super::super::parse_query("hello", &default_config()).unwrap();
738
739      assert!(matches!(mode, SearchMode::Pattern(_)));
740    }
741  }
742
743  mod resolve_case {
744    use pretty_assertions::assert_eq;
745
746    use super::*;
747
748    #[test]
749    fn it_returns_ignore_for_all_lowercase() {
750      let case = super::super::resolve_case("hello world", &default_config());
751
752      assert_eq!(case, CaseSensitivity::Ignore);
753    }
754
755    #[test]
756    fn it_returns_ignore_when_config_is_ignore() {
757      let config = SearchConfig {
758        case: "ignore".into(),
759        ..SearchConfig::default()
760      };
761
762      let case = super::super::resolve_case("Hello", &config);
763
764      assert_eq!(case, CaseSensitivity::Ignore);
765    }
766
767    #[test]
768    fn it_returns_sensitive_for_mixed_case() {
769      let case = super::super::resolve_case("Hello world", &default_config());
770
771      assert_eq!(case, CaseSensitivity::Sensitive);
772    }
773
774    #[test]
775    fn it_returns_sensitive_when_config_is_sensitive() {
776      let config = SearchConfig {
777        case: "sensitive".into(),
778        ..SearchConfig::default()
779      };
780
781      let case = super::super::resolve_case("hello", &config);
782
783      assert_eq!(case, CaseSensitivity::Sensitive);
784    }
785  }
786
787  mod try_extract_regex {
788    use pretty_assertions::assert_eq;
789
790    #[test]
791    fn it_extracts_pattern_from_slashes() {
792      let result = super::super::try_extract_regex("/foo.*bar/");
793
794      assert_eq!(result, Some("foo.*bar".into()));
795    }
796
797    #[test]
798    fn it_returns_none_for_empty_pattern() {
799      assert!(super::super::try_extract_regex("//").is_none());
800    }
801
802    #[test]
803    fn it_returns_none_for_no_slashes() {
804      assert!(super::super::try_extract_regex("hello").is_none());
805    }
806
807    #[test]
808    fn it_returns_none_for_single_slash() {
809      assert!(super::super::try_extract_regex("/hello").is_none());
810    }
811  }
812}