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