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