Skip to main content

doing_ops/
search.rs

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