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