Skip to main content

obsidian_core/
search.rs

1use std::collections::{BTreeSet, HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
5use ignore::WalkBuilder;
6use rayon::prelude::*;
7use regex::Regex;
8
9use crate::{Link, LocatedLink, Location, Note, NoteError, SearchError, common};
10
11#[derive(Debug, Clone, Copy)]
12enum CaseSensitivity {
13    Sensitive,
14    Ignore,
15    Smart,
16}
17
18#[derive(Debug, Clone, Copy)]
19pub enum SortOrder {
20    PathAsc,
21    PathDesc,
22    ModifiedAsc,
23    ModifiedDesc,
24    CreatedAsc,
25    CreatedDesc,
26}
27
28pub fn sort_notes<T>(items: &mut [Note], sort: &SortOrder) {
29    sort_notes_by(items, |n| Some(n), sort);
30}
31
32pub fn sort_notes_by<T>(items: &mut [T], key: impl Fn(&T) -> Option<&Note>, sort: &SortOrder) {
33    let fallback_path = PathBuf::new();
34    match sort {
35        SortOrder::PathAsc => items.sort_by(|a, b| {
36            let a_path = key(a).as_ref().map(|n| &n.path).unwrap_or(&fallback_path);
37            let b_path = key(b).as_ref().map(|n| &n.path).unwrap_or(&fallback_path);
38            a_path.cmp(b_path)
39        }),
40        SortOrder::PathDesc => items.sort_by(|a, b| {
41            let a_path = key(a).as_ref().map(|n| &n.path).unwrap_or(&fallback_path);
42            let b_path = key(b).as_ref().map(|n| &n.path).unwrap_or(&fallback_path);
43            b_path.cmp(a_path)
44        }),
45        SortOrder::ModifiedAsc => items.sort_by_key(|r| {
46            key(r)
47                .as_ref()
48                .map(|n| n.last_modified_time())
49                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
50        }),
51        SortOrder::ModifiedDesc => items.sort_by_key(|r| {
52            std::cmp::Reverse(
53                key(r)
54                    .as_ref()
55                    .map(|n| n.last_modified_time())
56                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
57            )
58        }),
59        SortOrder::CreatedAsc => items.sort_by_key(|r| {
60            key(r)
61                .as_ref()
62                .map(|n| n.creation_time())
63                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
64        }),
65        SortOrder::CreatedDesc => items.sort_by_key(|r| {
66            std::cmp::Reverse(
67                key(r)
68                    .as_ref()
69                    .map(|n| n.creation_time())
70                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
71            )
72        }),
73    }
74}
75
76/// A composable query for filtering notes in a vault.
77///
78/// # Filter semantics
79/// - [`and_glob`](SearchQuery::and_glob): AND — note's relative path matches one of these patterns
80/// - [`or_glob`](SearchQuery::or_glob): OR — note's relative path matches one of these patterns
81/// - [`and_has_id`](SearchQuery::and_has_id): OR — has this ID
82/// - [`or_has_id`](SearchQuery::or_has_id): OR — has one of these IDs
83/// - [`and_has_tag`](SearchQuery::and_has_tag): AND — note has all of these tags
84/// - [`or_has_tag`](SearchQuery::or_has_tag): OR — note has one of these tag
85/// - [`and_has_alias`](SearchQuery::and_has_alias): AND — note has all of these aliases (case insensitive)
86/// - [`or_has_alias`](SearchQuery::or_has_alias): OR — note has any of these aliases (case insensitive)
87/// - [`and_title_contains`](SearchQuery::and_title_contains): AND — title contains all of these substrings
88/// - [`or_title_contains`](SearchQuery::or_title_contains): OR — title contains any of these substrings
89/// - [`and_alias_contains`](SearchQuery::and_alias_contains): AND — all of these substrings must match some alias
90/// - [`or_alias_contains`](SearchQuery::or_alias_contains): OR — one of these substrings matches some alias
91/// - [`and_content_contains`](SearchQuery::and_content_contains): AND — content contains all of these substrings (case-sensitive)
92/// - [`or_content_contains`](SearchQuery::or_content_contains): OR — content contains any of these substrings (case-sensitive)
93/// - [`and_content_matches`](SearchQuery::and_content_matches): AND — content matches all of these patterns
94/// - [`or_content_matches`](SearchQuery::or_content_matches): OR — content matches any of these patterns
95pub struct SearchQuery<'a> {
96    config: SearchQueryConfig,
97    loaded_notes: Option<&'a HashMap<PathBuf, Note>>,
98}
99
100/// All owned, non-reference fields of [`SearchQuery`]. Extracted into its own struct so that
101/// [`SearchQuery::with_loaded_notes`] can change the lifetime parameter without reconstructing
102/// every field individually.
103struct SearchQueryConfig {
104    root: PathBuf,
105    and_globs: Vec<String>,
106    or_globs: Vec<String>,
107    and_id: Option<String>,
108    or_ids: Vec<String>,
109    and_tags: Vec<String>,
110    or_tags: Vec<String>,
111    and_title_contains: Vec<String>,
112    or_title_contains: Vec<String>,
113    and_aliases: Vec<String>,
114    or_aliases: Vec<String>,
115    and_alias_contains: Vec<String>,
116    or_alias_contains: Vec<String>,
117    and_content_contains: Vec<String>,
118    or_content_contains: Vec<String>,
119    and_content_matches: Vec<String>,
120    or_content_matches: Vec<String>,
121    and_links_to: Vec<Note>,
122    or_links_to: Vec<Note>,
123    case_sensitivity: Option<CaseSensitivity>,
124    include_inline_tags: bool,
125    sort_order: Option<SortOrder>,
126}
127
128impl SearchQuery<'static> {
129    pub fn new(root: impl AsRef<Path>) -> Self {
130        SearchQuery {
131            config: SearchQueryConfig {
132                root: root.as_ref().to_path_buf(),
133                and_globs: Vec::new(),
134                or_globs: Vec::new(),
135                and_id: None,
136                or_ids: Vec::new(),
137                and_tags: Vec::new(),
138                or_tags: Vec::new(),
139                and_title_contains: Vec::new(),
140                or_title_contains: Vec::new(),
141                and_aliases: Vec::new(),
142                or_aliases: Vec::new(),
143                and_alias_contains: Vec::new(),
144                or_alias_contains: Vec::new(),
145                and_content_contains: Vec::new(),
146                or_content_contains: Vec::new(),
147                and_content_matches: Vec::new(),
148                or_content_matches: Vec::new(),
149                and_links_to: Vec::new(),
150                or_links_to: Vec::new(),
151                case_sensitivity: None,
152                include_inline_tags: false,
153                sort_order: None,
154            },
155            loaded_notes: None,
156        }
157    }
158
159    /// Provide in-memory notes to use instead of their on-disk counterparts.
160    ///
161    /// Each note's `note.path` is matched against the vault's note paths on disk. Notes whose
162    /// paths exist on disk shadow the disk version; notes with no on-disk counterpart are included
163    /// as additional candidates. In-memory notes are assumed to have `content` populated whenever
164    /// content filters (e.g. [`and_content_contains`](Self::and_content_contains)) are used.
165    pub fn with_loaded_notes<'a>(self, notes: &'a HashMap<PathBuf, Note>) -> SearchQuery<'a> {
166        SearchQuery {
167            config: self.config,
168            loaded_notes: Some(notes),
169        }
170    }
171}
172
173impl<'a> SearchQuery<'a> {
174    /// Note path must match this glob pattern (matched against the note's path relative to the vault root).
175    pub fn and_glob(mut self, pattern: impl Into<String>) -> Self {
176        self.config.and_globs.push(pattern.into());
177        self
178    }
179
180    /// Note path could match this glob pattern (matched against the note's path relative to the vault root).
181    pub fn or_glob(mut self, pattern: impl Into<String>) -> Self {
182        self.config.or_globs.push(pattern.into());
183        self
184    }
185
186    /// Note must have this ID (case-sensitive by default).
187    pub fn and_has_id(mut self, id: impl Into<String>) -> Self {
188        self.config.and_id = Some(id.into());
189        self
190    }
191
192    /// Note could have this ID (case-sensitive by default).
193    pub fn or_has_id(mut self, id: impl Into<String>) -> Self {
194        self.config.or_ids.push(id.into());
195        self
196    }
197
198    /// Note must have this tag (case-insensitive by default).
199    pub fn and_has_tag(mut self, tag: impl Into<String>) -> Self {
200        self.config.and_tags.push(crate::tag::clean_tag(&tag.into()));
201        self
202    }
203
204    /// Note could have this tag (case-insensitive by default).
205    pub fn or_has_tag(mut self, tag: impl Into<String>) -> Self {
206        self.config.or_tags.push(crate::tag::clean_tag(&tag.into()));
207        self
208    }
209
210    /// Must title must contain this substring (smart case-sensitive by default).
211    pub fn and_title_contains(mut self, s: impl Into<String>) -> Self {
212        self.config.and_title_contains.push(s.into());
213        self
214    }
215
216    /// Must title could contain this substring (smart case-sensitive by default).
217    pub fn or_title_contains(mut self, s: impl Into<String>) -> Self {
218        self.config.or_title_contains.push(s.into());
219        self
220    }
221
222    /// Note must have this alias (smart case-sensitive by default).
223    pub fn and_has_alias(mut self, alias: impl Into<String>) -> Self {
224        self.config.and_aliases.push(alias.into());
225        self
226    }
227
228    /// Note could have this alias (smart case-sensitive by default).
229    pub fn or_has_alias(mut self, alias: impl Into<String>) -> Self {
230        self.config.or_aliases.push(alias.into());
231        self
232    }
233
234    /// Substring must match against any of the note's aliases (smart case-sensitive by default).
235    pub fn and_alias_contains(mut self, s: impl Into<String>) -> Self {
236        self.config.and_alias_contains.push(s.into());
237        self
238    }
239
240    /// Substring could match against any of the note's aliases (smart case-sensitive by default).
241    pub fn or_alias_contains(mut self, s: impl Into<String>) -> Self {
242        self.config.or_alias_contains.push(s.into());
243        self
244    }
245
246    /// Note body must contain this string (smart case-sensitive by default).
247    pub fn and_content_contains(mut self, s: impl Into<String>) -> Self {
248        self.config.and_content_contains.push(s.into());
249        self
250    }
251
252    /// Note body could contain this string (smart case-sensitive by default).
253    pub fn or_content_contains(mut self, s: impl Into<String>) -> Self {
254        self.config.or_content_contains.push(s.into());
255        self
256    }
257
258    /// Regex body must match this pattern (smart case-sensitive by default).
259    pub fn and_content_matches(mut self, pattern: impl Into<String>) -> Self {
260        self.config.and_content_matches.push(pattern.into());
261        self
262    }
263
264    /// Regex body could match this pattern (smart case-sensitive by default).
265    pub fn or_content_matches(mut self, pattern: impl Into<String>) -> Self {
266        self.config.or_content_matches.push(pattern.into());
267        self
268    }
269
270    /// Has a link to this note
271    pub fn and_links_to(mut self, note: Note) -> Self {
272        self.config.and_links_to.push(note);
273        self
274    }
275
276    /// May link to this note
277    pub fn or_links_to(mut self, note: Note) -> Self {
278        self.config.or_links_to.push(note);
279        self
280    }
281
282    /// Execute the search case-sensitively.
283    pub fn case_sensitive(mut self) -> Self {
284        self.config.case_sensitivity = Some(CaseSensitivity::Sensitive);
285        self
286    }
287
288    /// Execute the search case-insensitively.
289    pub fn ignore_case(mut self) -> Self {
290        self.config.case_sensitivity = Some(CaseSensitivity::Ignore);
291        self
292    }
293
294    /// Execute the search with smart case sensitivity: case-sensitive if the query contains any uppercase letters,
295    /// otherwise case-insensitive.
296    pub fn smart_case(mut self) -> Self {
297        self.config.case_sensitivity = Some(CaseSensitivity::Smart);
298        self
299    }
300
301    pub fn include_inline_tags(mut self) -> Self {
302        self.config.include_inline_tags = true;
303        self
304    }
305
306    pub fn sort_by(mut self, sort_order: SortOrder) -> Self {
307        self.config.sort_order = Some(sort_order);
308        self
309    }
310
311    /// Execute the query, returning matching notes.
312    ///
313    /// Returns `Err` if any glob or regex pattern is invalid.
314    /// Each inner `Err` represents an I/O failure loading a specific note.
315    pub fn execute(self) -> Result<Vec<Result<Note, NoteError>>, SearchError> {
316        let SearchQuery { config, loaded_notes } = self;
317        let SearchQueryConfig {
318            root,
319            and_globs,
320            or_globs,
321            and_id,
322            or_ids,
323            and_tags,
324            or_tags,
325            and_title_contains,
326            or_title_contains,
327            and_aliases,
328            or_aliases,
329            and_alias_contains,
330            or_alias_contains,
331            and_content_contains,
332            or_content_contains,
333            and_content_matches,
334            or_content_matches,
335            and_links_to,
336            or_links_to,
337            case_sensitivity,
338            include_inline_tags,
339            sort_order,
340        } = config;
341
342        let strings_equal = |s: &str, query: &str, cs: CaseSensitivity| match cs {
343            CaseSensitivity::Sensitive => s == query,
344            CaseSensitivity::Ignore => s.eq_ignore_ascii_case(query),
345            CaseSensitivity::Smart => {
346                if query.chars().any(|c| c.is_ascii_uppercase()) {
347                    s == query
348                } else {
349                    s.eq_ignore_ascii_case(query)
350                }
351            }
352        };
353        let string_contains = |s: &str, query: &str, cs: CaseSensitivity| match cs {
354            CaseSensitivity::Sensitive => s.contains(query),
355            CaseSensitivity::Ignore => s.to_lowercase().contains(&query.to_lowercase()),
356            CaseSensitivity::Smart => {
357                if query.chars().any(|c| c.is_ascii_uppercase()) {
358                    s.contains(query)
359                } else {
360                    s.to_lowercase().contains(&query.to_lowercase())
361                }
362            }
363        };
364        let compare_tag = |note_tag: &str, query_tag: &str, cs: CaseSensitivity| match cs {
365            CaseSensitivity::Sensitive => note_tag == query_tag || note_tag.starts_with(&format!("{query_tag}/")),
366            CaseSensitivity::Ignore => {
367                note_tag.eq_ignore_ascii_case(query_tag)
368                    || note_tag
369                        .to_lowercase()
370                        .starts_with(&format!("{}/", query_tag.to_lowercase()))
371            }
372            CaseSensitivity::Smart => {
373                if query_tag.chars().any(|c| c.is_ascii_uppercase()) {
374                    note_tag == query_tag || note_tag.starts_with(&format!("{query_tag}/"))
375                } else {
376                    note_tag.eq_ignore_ascii_case(query_tag)
377                        || note_tag
378                            .to_lowercase()
379                            .starts_with(&format!("{}/", query_tag.to_lowercase()))
380                }
381            }
382        };
383
384        let and_glob_set = build_glob_set(&and_globs)?;
385        let or_glob_set = build_glob_set(&or_globs)?;
386
387        // Build a set of paths covered by in-memory overrides so they can be excluded from the disk walk.
388        let override_paths: HashSet<&Path> = loaded_notes
389            .map(|m| m.keys().map(|p| p.as_path()).collect())
390            .unwrap_or_default();
391
392        let paths: Vec<PathBuf> = find_note_paths(&root)
393            .filter(|path| !override_paths.contains(path.as_path()))
394            .filter(|path| {
395                if and_globs.is_empty() {
396                    return true;
397                }
398                let rel = path.strip_prefix(&root).unwrap_or(path);
399                and_glob_set.is_match(rel)
400            })
401            .collect();
402
403        let mut and_regexes: Vec<Regex> = Vec::new();
404        for pattern in and_content_matches {
405            let pattern = match case_sensitivity.unwrap_or(CaseSensitivity::Smart) {
406                CaseSensitivity::Sensitive => pattern,
407                CaseSensitivity::Ignore => format!("(?i:{pattern})"),
408                CaseSensitivity::Smart => {
409                    if pattern.chars().any(|c| c.is_ascii_uppercase()) {
410                        pattern
411                    } else {
412                        format!("(?i:{pattern})")
413                    }
414                }
415            };
416            let re = Regex::new(&pattern).map_err(SearchError::InvalidRegex)?;
417            and_regexes.push(re);
418        }
419
420        let mut or_regexes: Vec<Regex> = Vec::new();
421        for pattern in or_content_matches {
422            let pattern = match case_sensitivity.unwrap_or(CaseSensitivity::Smart) {
423                CaseSensitivity::Sensitive => pattern,
424                CaseSensitivity::Ignore => format!("(?i){pattern}"),
425                CaseSensitivity::Smart => {
426                    if pattern.chars().any(|c| c.is_ascii_uppercase()) {
427                        pattern
428                    } else {
429                        format!("(?i){pattern}")
430                    }
431                }
432            };
433            let re = Regex::new(&pattern).map_err(SearchError::InvalidRegex)?;
434            or_regexes.push(re);
435        }
436
437        let needs_content = !and_content_contains.is_empty()
438            || !or_content_contains.is_empty()
439            || !and_regexes.is_empty()
440            || !or_regexes.is_empty();
441        let has_or_filters = !or_globs.is_empty()
442            || !or_ids.is_empty()
443            || !or_tags.is_empty()
444            || !or_title_contains.is_empty()
445            || !or_aliases.is_empty()
446            || !or_alias_contains.is_empty()
447            || !or_content_contains.is_empty()
448            || !or_regexes.is_empty()
449            || !or_links_to.is_empty();
450        let has_filters = has_or_filters
451            || and_id.is_some()
452            || !and_tags.is_empty()
453            || !and_title_contains.is_empty()
454            || !and_aliases.is_empty()
455            || !and_alias_contains.is_empty()
456            || !and_content_contains.is_empty()
457            || !and_regexes.is_empty()
458            || !and_links_to.is_empty();
459
460        // Shared filter closure: apply all AND/OR filters to an already-loaded note.
461        // `rel` is the note's path relative to the vault root (used for or_glob matching).
462        let filter_note = |note: Note, rel: &Path| -> Option<Result<Note, NoteError>> {
463            if !has_filters {
464                return Some(Ok(note));
465            }
466
467            // ---------------------------------------------------------------------
468            // Begin AND filters. Exclude note immediately if it fails any of these.
469            // ---------------------------------------------------------------------
470            if let Some(ref expected_id) = and_id
471                && !strings_equal(
472                    &note.id,
473                    expected_id,
474                    case_sensitivity.unwrap_or(CaseSensitivity::Sensitive),
475                )
476            {
477                return None;
478            }
479
480            if !and_tags.is_empty()
481                && !and_tags.iter().all(|t| {
482                    note.tags.iter().any(|lt| {
483                        (include_inline_tags || matches!(lt.location, Location::Frontmatter))
484                            && compare_tag(&lt.tag, t, case_sensitivity.unwrap_or(CaseSensitivity::Ignore))
485                    })
486                })
487            {
488                return None;
489            }
490
491            if !and_aliases.is_empty()
492                && !and_aliases.iter().all(|a| {
493                    note.aliases
494                        .iter()
495                        .any(|na| strings_equal(na, a, case_sensitivity.unwrap_or(CaseSensitivity::Smart)))
496                })
497            {
498                return None;
499            }
500
501            if !and_title_contains.is_empty()
502                && !and_title_contains.iter().all(|substr| {
503                    note.title
504                        .as_deref()
505                        .is_some_and(|t| string_contains(t, substr, case_sensitivity.unwrap_or(CaseSensitivity::Smart)))
506                })
507            {
508                return None;
509            }
510
511            if !and_alias_contains.is_empty()
512                && !and_alias_contains.iter().all(|substr| {
513                    note.aliases
514                        .iter()
515                        .any(|a| string_contains(a, substr, case_sensitivity.unwrap_or(CaseSensitivity::Smart)))
516                })
517            {
518                return None;
519            }
520
521            if !and_content_contains.is_empty()
522                && !and_content_contains.iter().all(|s| {
523                    string_contains(
524                        note.body.as_deref().unwrap(),
525                        s,
526                        case_sensitivity.unwrap_or(CaseSensitivity::Smart),
527                    )
528                })
529            {
530                return None;
531            }
532
533            if !and_regexes.is_empty() && !and_regexes.iter().all(|re| re.is_match(note.body.as_deref().unwrap())) {
534                return None;
535            }
536
537            if !and_links_to.is_empty()
538                && !and_links_to
539                    .iter()
540                    .all(|n| !find_matching_links(&note, n, &root).is_empty())
541            {
542                return None;
543            }
544
545            // --------------------------------------------------------------------------------------------
546            // Begin OR filters. Include note if it satisfies any of these (or if there are no OR filters).
547            // --------------------------------------------------------------------------------------------
548            if !has_or_filters {
549                return Some(Ok(note));
550            }
551
552            if !or_globs.is_empty() && or_glob_set.is_match(rel) {
553                return Some(Ok(note));
554            }
555
556            if or_ids
557                .iter()
558                .any(|id| strings_equal(&note.id, id, case_sensitivity.unwrap_or(CaseSensitivity::Sensitive)))
559            {
560                return Some(Ok(note));
561            }
562
563            if or_tags.iter().any(|t| {
564                note.tags.iter().any(|lt| {
565                    (include_inline_tags || matches!(lt.location, Location::Frontmatter))
566                        && compare_tag(&lt.tag, t, case_sensitivity.unwrap_or(CaseSensitivity::Ignore))
567                })
568            }) {
569                return Some(Ok(note));
570            }
571
572            if or_title_contains.iter().any(|substr| {
573                note.title
574                    .as_deref()
575                    .is_some_and(|t| string_contains(t, substr, case_sensitivity.unwrap_or(CaseSensitivity::Smart)))
576            }) {
577                return Some(Ok(note));
578            }
579
580            if or_aliases.iter().any(|a| {
581                note.aliases
582                    .iter()
583                    .any(|na| strings_equal(na, a, case_sensitivity.unwrap_or(CaseSensitivity::Smart)))
584            }) {
585                return Some(Ok(note));
586            }
587
588            if or_alias_contains.iter().any(|substr| {
589                note.aliases
590                    .iter()
591                    .any(|a| string_contains(a, substr, case_sensitivity.unwrap_or(CaseSensitivity::Smart)))
592            }) {
593                return Some(Ok(note));
594            }
595
596            if or_content_contains.iter().any(|s| {
597                string_contains(
598                    note.body.as_deref().unwrap(),
599                    s,
600                    case_sensitivity.unwrap_or(CaseSensitivity::Smart),
601                )
602            }) {
603                return Some(Ok(note));
604            }
605
606            if or_regexes.iter().any(|re| re.is_match(note.body.as_deref().unwrap())) {
607                return Some(Ok(note));
608            }
609
610            if or_links_to
611                .iter()
612                .any(|n| !find_matching_links(&note, n, &root).is_empty())
613            {
614                return Some(Ok(note));
615            }
616
617            None
618        };
619
620        // Process disk notes in parallel.
621        let mut results: Vec<Result<Note, NoteError>> = paths
622            .into_par_iter()
623            .filter_map(|path| -> Option<Result<Note, NoteError>> {
624                let rel = path.strip_prefix(&root).unwrap_or(&path);
625                let load = if needs_content {
626                    Note::from_path_with_body(&path)
627                } else {
628                    Note::from_path(&path)
629                };
630                let note = match load {
631                    Ok(n) => n,
632                    Err(e) => return Some(Err(e)),
633                };
634                filter_note(note, rel)
635            })
636            .collect();
637
638        // Process in-memory override notes sequentially (typically a small set).
639        if let Some(notes) = loaded_notes {
640            for note in notes.values() {
641                // Apply and_glob pre-filter.
642                if !and_globs.is_empty() {
643                    let rel = note.path.strip_prefix(&root).unwrap_or(&note.path);
644                    if !and_glob_set.is_match(rel) {
645                        continue;
646                    }
647                }
648                // Guard against missing content when content filters are active.
649                if needs_content && note.body.is_none() {
650                    results.push(Err(NoteError::BodyNotLoaded));
651                    continue;
652                }
653                // Compute rel as owned PathBuf so we can move the cloned note into filter_note.
654                let rel_buf = note.path.strip_prefix(&root).unwrap_or(&note.path).to_path_buf();
655                if let Some(result) = filter_note(note.clone(), &rel_buf) {
656                    results.push(result);
657                }
658            }
659        }
660
661        if let Some(sort_order) = sort_order {
662            sort_notes_by(&mut results, |r| r.as_ref().ok(), &sort_order);
663        };
664
665        Ok(results)
666    }
667}
668
669fn build_glob_set(patterns: &[String]) -> Result<GlobSet, SearchError> {
670    let mut builder = GlobSetBuilder::new();
671    for pattern in patterns {
672        let glob = GlobBuilder::new(pattern).literal_separator(true).build()?;
673        builder.add(glob);
674    }
675    Ok(builder.build()?)
676}
677
678/// Returns an iterator over all `.md` file paths found recursively under `root`.
679/// Respects `.gitignore`, `.git/info/exclude`, and `.ignore` files.
680pub fn find_note_paths(root: impl AsRef<Path>) -> impl Iterator<Item = PathBuf> {
681    WalkBuilder::new(root)
682        .build()
683        .filter_map(|entry| entry.ok())
684        .filter(|entry| {
685            entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)
686                && entry.path().extension().and_then(|e| e.to_str()) == Some("md")
687        })
688        .map(|entry| entry.into_path())
689}
690
691/// Loads all notes found recursively under `root` in parallel, without retaining body content.
692pub fn find_notes(root: impl AsRef<Path>) -> Vec<Result<Note, NoteError>> {
693    find_note_paths(root)
694        .collect::<Vec<_>>()
695        .into_par_iter()
696        .map(Note::from_path)
697        .collect()
698}
699
700/// Find all tags used across the vault. When `loaded_notes` is provided, notes whose paths appear
701/// in the map are excluded from the disk walk and the in-memory versions are used instead.
702pub fn find_all_tags(
703    root: impl AsRef<Path>,
704    loaded_notes: Option<&HashMap<PathBuf, Note>>,
705) -> Result<Vec<String>, NoteError> {
706    let root = root.as_ref();
707    let override_paths: HashSet<&Path> = loaded_notes
708        .map(|m| m.keys().map(|p| p.as_path()).collect())
709        .unwrap_or_default();
710
711    let mut tags: BTreeSet<String> = find_note_paths(root)
712        .filter(|p| !override_paths.contains(p.as_path()))
713        .collect::<Vec<_>>()
714        .into_par_iter()
715        .map(Note::from_path)
716        .filter_map(|res| match res {
717            Ok(note) => {
718                let tags: BTreeSet<String> = note.tags.into_iter().map(|lt| lt.tag.to_lowercase()).collect();
719                Some(Ok(tags))
720            }
721            Err(e) => Some(Err(e)),
722        })
723        .flatten()
724        .flatten()
725        .collect::<BTreeSet<String>>();
726
727    // Include tags from in-memory notes.
728    if let Some(notes) = loaded_notes {
729        for note in notes.values() {
730            for lt in &note.tags {
731                tags.insert(lt.tag.to_lowercase());
732            }
733        }
734    }
735
736    Ok(tags.into_iter().collect())
737}
738
739/// Find occurrences of specific tags. Returns a list of located tags grouped by the note in which
740/// they were found. When `loaded_notes` is provided, notes whose paths appear in the map are
741/// excluded from the disk walk and the in-memory versions are used instead.
742pub fn find_tags(
743    root: impl AsRef<Path>,
744    tags: &[String],
745    loaded_notes: Option<&HashMap<PathBuf, Note>>,
746) -> Result<Vec<(Note, Vec<crate::LocatedTag>)>, SearchError> {
747    let tags = tags.iter().map(|t| crate::tag::clean_tag(t)).collect::<Vec<String>>();
748    let root_ref = root.as_ref();
749    let notes: Vec<Note> = if let Some(loaded) = loaded_notes {
750        let mut q = SearchQuery::new(root_ref)
751            .include_inline_tags()
752            .with_loaded_notes(loaded);
753        for tag in &tags {
754            q = q.or_has_tag(tag);
755        }
756        q.execute()?.into_iter().filter_map(|r| r.ok()).collect()
757    } else {
758        let mut q = SearchQuery::new(root_ref).include_inline_tags();
759        for tag in &tags {
760            q = q.or_has_tag(tag);
761        }
762        q.execute()?.into_iter().filter_map(|r| r.ok()).collect()
763    };
764
765    // A note tag matches a search term if it equals the term exactly or is a sub-tag of it
766    // (e.g. "workout/upper-body" matches search term "workout").
767    let tag_matches_search = |tag: &str| {
768        tags.iter()
769            .any(|s| tag.eq_ignore_ascii_case(s) || tag.to_lowercase().starts_with(&format!("{}/", s.to_lowercase())))
770    };
771
772    let results: Vec<(Note, Vec<crate::LocatedTag>)> = notes
773        .into_iter()
774        .filter_map(|note| {
775            let matched: Vec<crate::LocatedTag> = note
776                .tags
777                .iter()
778                .filter_map(|lt| {
779                    if tag_matches_search(&lt.tag) {
780                        Some(lt.clone())
781                    } else {
782                        None
783                    }
784                })
785                .collect();
786            if matched.is_empty() {
787                None
788            } else {
789                Some((note, matched))
790            }
791        })
792        .collect();
793
794    Ok(results)
795}
796
797/// Like [`find_notes`], but retains body content in each [`Note::content`].
798pub fn find_notes_with_content(root: impl AsRef<Path>) -> Vec<Result<Note, NoteError>> {
799    find_note_paths(root)
800        .collect::<Vec<_>>()
801        .into_par_iter()
802        .map(Note::from_path_with_body)
803        .collect()
804}
805
806/// Like [`find_notes`], but only loads notes whose path satisfies `filter`.
807/// Filtering happens before any file I/O, so non-matching files are never read.
808/// When `loaded_notes` is provided, notes whose paths appear in the map are excluded from the
809/// disk walk and the in-memory versions are used instead (if they also pass `filter`).
810pub fn find_notes_filtered(
811    root: impl AsRef<Path>,
812    filter: impl Fn(&Path) -> bool,
813    loaded_notes: Option<&HashMap<PathBuf, Note>>,
814) -> Vec<Result<Note, NoteError>> {
815    let root = root.as_ref();
816    let override_paths: HashSet<&Path> = loaded_notes
817        .map(|m| m.keys().map(|p| p.as_path()).collect())
818        .unwrap_or_default();
819
820    let mut results: Vec<Result<Note, NoteError>> = find_note_paths(root)
821        .filter(|path| !override_paths.contains(path.as_path()))
822        .filter(|path| filter(path))
823        .collect::<Vec<_>>()
824        .into_par_iter()
825        .map(Note::from_path)
826        .collect();
827
828    if let Some(notes) = loaded_notes {
829        for note in notes.values() {
830            if filter(&note.path) {
831                results.push(Ok(note.clone()));
832            }
833        }
834    }
835
836    results
837}
838
839/// Like [`find_notes_filtered`], but retains body content in each [`Note::content`].
840pub fn find_notes_filtered_with_content(
841    root: impl AsRef<Path>,
842    filter: impl Fn(&Path) -> bool,
843    loaded_notes: Option<&HashMap<PathBuf, Note>>,
844) -> Vec<Result<Note, NoteError>> {
845    let root = root.as_ref();
846    let override_paths: HashSet<&Path> = loaded_notes
847        .map(|m| m.keys().map(|p| p.as_path()).collect())
848        .unwrap_or_default();
849
850    let mut results: Vec<Result<Note, NoteError>> = find_note_paths(root)
851        .filter(|path| !override_paths.contains(path.as_path()))
852        .filter(|path| filter(path))
853        .collect::<Vec<_>>()
854        .into_par_iter()
855        .map(Note::from_path_with_body)
856        .collect();
857
858    if let Some(notes) = loaded_notes {
859        for note in notes.values() {
860            if filter(&note.path) {
861                results.push(Ok(note.clone()));
862            }
863        }
864    }
865
866    results
867}
868
869/// Returns all links in `source` that point to `target`, using the vault root `vault_path`
870/// for resolving relative markdown URLs. Returns an empty vec if `source` is `target`.
871pub fn find_matching_links(source: &Note, target: &Note, vault_path: &std::path::Path) -> Vec<LocatedLink> {
872    if source.path == target.path {
873        return Vec::new();
874    }
875    let target_stem = target.path.file_stem().and_then(|s| s.to_str()).map(|s| s.to_string());
876    source
877        .links
878        .clone()
879        .into_iter()
880        .filter(|ll| match &ll.link {
881            Link::Wiki {
882                target: wiki_target, ..
883            } => {
884                wiki_target == &target.id
885                    || target_stem.as_deref().is_some_and(|s| wiki_target == s)
886                    || target.aliases.iter().any(|a| wiki_target == a)
887            }
888            Link::Markdown { url, .. } => {
889                if url.contains("://") || url.starts_with('/') {
890                    return false;
891                }
892                let url_path = match url.find('#') {
893                    Some(i) => &url[..i],
894                    None => url.as_str(),
895                };
896                if !url_path.ends_with(".md") {
897                    return false;
898                }
899                let source_dir = source.path.parent().unwrap_or(&source.path);
900                (common::normalize_path(source_dir.join(url_path), Some(vault_path)) == target.path)
901                    || (url_path == common::relative_path(vault_path, &target.path).to_string_lossy())
902            }
903            _ => false,
904        })
905        .collect()
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911    use std::fs;
912
913    fn write_note(path: &std::path::Path, content: &str) {
914        if let Some(parent) = path.parent() {
915            fs::create_dir_all(parent).unwrap();
916        }
917        fs::write(path, content).unwrap();
918    }
919
920    fn unwrap_notes(results: Vec<Result<Note, crate::NoteError>>) -> Vec<Note> {
921        results.into_iter().map(|r| r.unwrap()).collect()
922    }
923
924    fn sorted_ids(notes: Vec<Note>) -> Vec<String> {
925        let mut ids: Vec<String> = notes.into_iter().map(|n| n.id).collect();
926        ids.sort();
927        ids
928    }
929
930    #[test]
931    fn glob_filters_by_relative_path() {
932        let dir = tempfile::tempdir().unwrap();
933        let subdir = dir.path().join("subdir");
934        write_note(&dir.path().join("root.md"), "root note");
935        write_note(&subdir.join("sub.md"), "sub note");
936
937        let results = SearchQuery::new(dir.path()).and_glob("subdir/**").execute().unwrap();
938        let notes = unwrap_notes(results);
939        assert_eq!(notes.len(), 1);
940        assert!(notes[0].path.ends_with("subdir/sub.md"));
941    }
942
943    #[test]
944    fn multiple_globs_or_semantics() {
945        let dir = tempfile::tempdir().unwrap();
946        for d in ["a", "b", "c"] {
947            write_note(&dir.path().join(d).join("note.md"), d);
948        }
949
950        let notes = unwrap_notes(
951            SearchQuery::new(dir.path())
952                .and_glob("a/**")
953                .and_glob("b/**")
954                .execute()
955                .unwrap(),
956        );
957        let mut paths: Vec<String> = notes
958            .iter()
959            .map(|n| {
960                n.path
961                    .parent()
962                    .unwrap()
963                    .file_name()
964                    .unwrap()
965                    .to_string_lossy()
966                    .into_owned()
967            })
968            .collect();
969        paths.sort();
970        assert_eq!(paths, vec!["a", "b"]);
971    }
972
973    #[test]
974    fn glob_no_match_returns_empty() {
975        let dir = tempfile::tempdir().unwrap();
976        write_note(&dir.path().join("note.md"), "content");
977
978        let notes = unwrap_notes(
979            SearchQuery::new(dir.path())
980                .and_glob("nonexistent/**")
981                .execute()
982                .unwrap(),
983        );
984        assert!(notes.is_empty());
985    }
986
987    #[test]
988    fn and_has_tag_single() {
989        let dir = tempfile::tempdir().unwrap();
990        write_note(&dir.path().join("tagged.md"), "---\ntags: [rust]\n---\nContent.");
991        write_note(&dir.path().join("untagged.md"), "No tags here.");
992
993        let ids = sorted_ids(unwrap_notes(
994            SearchQuery::new(dir.path()).and_has_tag("rust").execute().unwrap(),
995        ));
996        assert_eq!(ids, vec!["tagged"]);
997    }
998
999    #[test]
1000    fn and_tag_semantics() {
1001        let dir = tempfile::tempdir().unwrap();
1002        write_note(
1003            &dir.path().join("both.md"),
1004            "---\ntags: [rust, obsidian]\n---\nContent.",
1005        );
1006        write_note(&dir.path().join("one.md"), "---\ntags: [rust]\n---\nContent.");
1007        write_note(&dir.path().join("none.md"), "No tags.");
1008
1009        let ids = sorted_ids(unwrap_notes(
1010            SearchQuery::new(dir.path())
1011                .and_has_tag("rust")
1012                .and_has_tag("obsidian")
1013                .execute()
1014                .unwrap(),
1015        ));
1016        assert_eq!(ids, vec!["both"]);
1017
1018        let ids = sorted_ids(unwrap_notes(
1019            SearchQuery::new(dir.path())
1020                .or_has_tag("rust")
1021                .or_has_tag("obsidian")
1022                .execute()
1023                .unwrap(),
1024        ));
1025        assert_eq!(ids, vec!["both", "one"]);
1026    }
1027
1028    #[test]
1029    fn and_has_tag_no_match() {
1030        let dir = tempfile::tempdir().unwrap();
1031        write_note(&dir.path().join("note.md"), "---\ntags: [rust]\n---\nContent.");
1032
1033        let notes = unwrap_notes(SearchQuery::new(dir.path()).and_has_tag("python").execute().unwrap());
1034        assert!(notes.is_empty());
1035    }
1036
1037    #[test]
1038    fn id_exact_match() {
1039        let dir = tempfile::tempdir().unwrap();
1040        write_note(&dir.path().join("note-a.md"), "---\nid: my-special-id\n---\nContent.");
1041        write_note(&dir.path().join("note-b.md"), "Other note.");
1042
1043        let ids = sorted_ids(unwrap_notes(
1044            SearchQuery::new(dir.path())
1045                .and_has_id("my-special-id")
1046                .execute()
1047                .unwrap(),
1048        ));
1049        assert_eq!(ids, vec!["my-special-id"]);
1050    }
1051
1052    #[test]
1053    fn title_contains_case_insensitive() {
1054        let dir = tempfile::tempdir().unwrap();
1055        write_note(&dir.path().join("match.md"), "# Rust Programming\n\nContent.");
1056        write_note(&dir.path().join("no-match.md"), "# Python Notes\n\nContent.");
1057
1058        let ids = sorted_ids(unwrap_notes(
1059            SearchQuery::new(dir.path())
1060                .and_title_contains("rust")
1061                .execute()
1062                .unwrap(),
1063        ));
1064        assert_eq!(ids, vec!["match"]);
1065    }
1066
1067    #[test]
1068    fn title_contains_no_title_excluded() {
1069        let dir = tempfile::tempdir().unwrap();
1070        write_note(&dir.path().join("no-title.md"), "Just plain content, no heading.");
1071        write_note(&dir.path().join("has-title.md"), "# My Title\n\nContent.");
1072
1073        let ids = sorted_ids(unwrap_notes(
1074            SearchQuery::new(dir.path()).and_title_contains("my").execute().unwrap(),
1075        ));
1076        assert_eq!(ids, vec!["has-title"]);
1077    }
1078
1079    #[test]
1080    fn or_has_alias_or_semantics() {
1081        let dir = tempfile::tempdir().unwrap();
1082        write_note(
1083            &dir.path().join("alpha.md"),
1084            "---\ntitle: Note Alpha\naliases: [alpha-alias]\n---\nContent.",
1085        );
1086        write_note(
1087            &dir.path().join("beta.md"),
1088            "---\ntitle: Note Beta\naliases: [beta-alias]\n---\nContent.",
1089        );
1090        write_note(&dir.path().join("gamma.md"), "# Gamma\n\nContent.");
1091
1092        let ids = sorted_ids(unwrap_notes(
1093            SearchQuery::new(dir.path())
1094                .or_has_alias("alpha-alias")
1095                .or_has_alias("beta-alias")
1096                .execute()
1097                .unwrap(),
1098        ));
1099        assert_eq!(ids, vec!["alpha", "beta"]);
1100    }
1101
1102    #[test]
1103    fn title_contains_or_semantics() {
1104        let dir = tempfile::tempdir().unwrap();
1105        write_note(&dir.path().join("rust.md"), "# Rust Language\n\nContent.");
1106        write_note(&dir.path().join("notes.md"), "# Programming Notes\n\nContent.");
1107        write_note(&dir.path().join("other.md"), "# Something Else\n\nContent.");
1108
1109        let ids = sorted_ids(unwrap_notes(
1110            SearchQuery::new(dir.path())
1111                .or_title_contains("rust")
1112                .or_title_contains("notes")
1113                .execute()
1114                .unwrap(),
1115        ));
1116        assert_eq!(ids, vec!["notes", "rust"]);
1117    }
1118
1119    #[test]
1120    fn alias_contains_or_semantics() {
1121        let dir = tempfile::tempdir().unwrap();
1122        write_note(
1123            &dir.path().join("rust.md"),
1124            "---\naliases: [Rust Language]\n---\nContent.",
1125        );
1126        write_note(
1127            &dir.path().join("notes.md"),
1128            "---\naliases: [Programming Notes]\n---\nContent.",
1129        );
1130        write_note(&dir.path().join("other.md"), "No aliases.");
1131
1132        let ids = sorted_ids(unwrap_notes(
1133            SearchQuery::new(dir.path())
1134                .or_alias_contains("rust")
1135                .or_alias_contains("notes")
1136                .execute()
1137                .unwrap(),
1138        ));
1139        assert_eq!(ids, vec!["notes", "rust"]);
1140    }
1141
1142    #[test]
1143    fn and_has_alias_case_insensitive() {
1144        let dir = tempfile::tempdir().unwrap();
1145        write_note(
1146            &dir.path().join("note.md"),
1147            "---\naliases: [Rust Programming]\n---\nContent.",
1148        );
1149
1150        let ids = sorted_ids(unwrap_notes(
1151            SearchQuery::new(dir.path())
1152                .and_has_alias("rust programming")
1153                .execute()
1154                .unwrap(),
1155        ));
1156        assert_eq!(ids, vec!["note"]);
1157    }
1158
1159    #[test]
1160    fn alias_contains_case_insensitive() {
1161        let dir = tempfile::tempdir().unwrap();
1162        write_note(
1163            &dir.path().join("match.md"),
1164            "---\naliases: [Rust Programming]\n---\nContent.",
1165        );
1166        write_note(
1167            &dir.path().join("no-match.md"),
1168            "---\naliases: [Python Notes]\n---\nContent.",
1169        );
1170
1171        let ids = sorted_ids(unwrap_notes(
1172            SearchQuery::new(dir.path())
1173                .and_alias_contains("rust")
1174                .execute()
1175                .unwrap(),
1176        ));
1177        assert_eq!(ids, vec!["match"]);
1178    }
1179
1180    #[test]
1181    fn alias_contains_matches_any_alias() {
1182        let dir = tempfile::tempdir().unwrap();
1183        write_note(
1184            &dir.path().join("note.md"),
1185            "---\naliases: [alpha, beta-suffix]\n---\nContent.",
1186        );
1187
1188        let ids = sorted_ids(unwrap_notes(
1189            SearchQuery::new(dir.path())
1190                .and_alias_contains("suffix")
1191                .execute()
1192                .unwrap(),
1193        ));
1194        assert_eq!(ids, vec!["note"]);
1195    }
1196
1197    #[test]
1198    fn alias_contains_no_match_excluded() {
1199        let dir = tempfile::tempdir().unwrap();
1200        write_note(&dir.path().join("note.md"), "---\naliases: [alpha]\n---\nContent.");
1201
1202        let notes = unwrap_notes(
1203            SearchQuery::new(dir.path())
1204                .and_alias_contains("beta")
1205                .execute()
1206                .unwrap(),
1207        );
1208        assert!(notes.is_empty());
1209    }
1210
1211    #[test]
1212    fn alias_contains_no_aliases_excluded() {
1213        let dir = tempfile::tempdir().unwrap();
1214        write_note(&dir.path().join("note.md"), "No aliases here.");
1215
1216        let notes = unwrap_notes(
1217            SearchQuery::new(dir.path())
1218                .and_alias_contains("anything")
1219                .execute()
1220                .unwrap(),
1221        );
1222        assert!(notes.is_empty());
1223    }
1224
1225    #[test]
1226    fn content_contains_single() {
1227        let dir = tempfile::tempdir().unwrap();
1228        write_note(&dir.path().join("match.md"), "This note mentions ferris.");
1229        write_note(&dir.path().join("no-match.md"), "This note mentions nothing special.");
1230
1231        let ids = sorted_ids(unwrap_notes(
1232            SearchQuery::new(dir.path())
1233                .and_content_contains("ferris")
1234                .execute()
1235                .unwrap(),
1236        ));
1237        assert_eq!(ids, vec!["match"]);
1238    }
1239
1240    #[test]
1241    fn content_contains_and_semantics() {
1242        let dir = tempfile::tempdir().unwrap();
1243        write_note(&dir.path().join("both.md"), "Contains alpha and beta.");
1244        write_note(&dir.path().join("one.md"), "Contains alpha only.");
1245        write_note(&dir.path().join("none.md"), "Contains neither.");
1246
1247        let ids = sorted_ids(unwrap_notes(
1248            SearchQuery::new(dir.path())
1249                .and_content_contains("alpha")
1250                .and_content_contains("beta")
1251                .execute()
1252                .unwrap(),
1253        ));
1254        assert_eq!(ids, vec!["both"]);
1255    }
1256
1257    #[test]
1258    fn content_matches_regex() {
1259        let dir = tempfile::tempdir().unwrap();
1260        write_note(&dir.path().join("match.md"), "Score: 42 points");
1261        write_note(&dir.path().join("no-match.md"), "No numbers here.");
1262
1263        let ids = sorted_ids(unwrap_notes(
1264            SearchQuery::new(dir.path())
1265                .and_content_matches(r"\d+")
1266                .execute()
1267                .unwrap(),
1268        ));
1269        assert_eq!(ids, vec!["match"]);
1270    }
1271
1272    #[test]
1273    fn content_matches_invalid_regex_errors() {
1274        let dir = tempfile::tempdir().unwrap();
1275        let result = SearchQuery::new(dir.path()).and_content_matches(r"[invalid").execute();
1276        assert!(matches!(result, Err(SearchError::InvalidRegex(_))));
1277    }
1278
1279    #[test]
1280    fn invalid_glob_errors() {
1281        let dir = tempfile::tempdir().unwrap();
1282        let result = SearchQuery::new(dir.path()).and_glob("[invalid").execute();
1283        assert!(matches!(result, Err(SearchError::InvalidGlob(_))));
1284    }
1285
1286    #[test]
1287    fn combined_glob_and_tag_content() {
1288        let dir = tempfile::tempdir().unwrap();
1289        let subdir = dir.path().join("notes");
1290
1291        write_note(
1292            &subdir.join("target.md"),
1293            "---\ntags: [rust]\n---\nThis note mentions ferris.",
1294        );
1295        write_note(
1296            &dir.path().join("wrong-glob.md"),
1297            "---\ntags: [rust]\n---\nThis note mentions ferris.",
1298        );
1299
1300        let ids = sorted_ids(unwrap_notes(
1301            SearchQuery::new(dir.path())
1302                .and_glob("notes/**")
1303                .and_has_tag("rust")
1304                .and_content_contains("ferris")
1305                .execute()
1306                .unwrap(),
1307        ));
1308        assert_eq!(ids, vec!["target"]);
1309    }
1310
1311    #[test]
1312    fn empty_query_returns_all_notes() {
1313        let dir = tempfile::tempdir().unwrap();
1314        write_note(&dir.path().join("a.md"), "Note A.");
1315        write_note(&dir.path().join("b.md"), "Note B.");
1316        write_note(&dir.path().join("c.md"), "Note C.");
1317
1318        let via_query = unwrap_notes(SearchQuery::new(dir.path()).execute().unwrap());
1319        let via_find = find_notes(dir.path())
1320            .into_iter()
1321            .map(|r| r.unwrap())
1322            .collect::<Vec<_>>();
1323
1324        assert_eq!(via_query.len(), via_find.len());
1325        assert_eq!(via_query.len(), 3);
1326    }
1327
1328    #[test]
1329    fn gitignore_excludes_ignored_notes() {
1330        let dir = tempfile::tempdir().unwrap();
1331        write_note(&dir.path().join("included.md"), "Normal note.");
1332        write_note(&dir.path().join("excluded").join("secret.md"), "Ignored note.");
1333        fs::write(dir.path().join(".ignore"), "excluded/\n").unwrap();
1334
1335        let paths: Vec<PathBuf> = find_note_paths(dir.path()).collect();
1336        assert_eq!(paths.len(), 1);
1337        assert!(paths[0].ends_with("included.md"));
1338    }
1339
1340    #[test]
1341    fn vault_search_convenience() {
1342        let dir = tempfile::tempdir().unwrap();
1343        write_note(&dir.path().join("tagged.md"), "---\ntags: [my-tag]\n---\nContent.");
1344        write_note(&dir.path().join("untagged.md"), "No tags.");
1345
1346        let vault = crate::Vault::open(dir.path()).unwrap();
1347        let ids = sorted_ids(unwrap_notes(vault.search().and_has_tag("my-tag").execute().unwrap()));
1348        assert_eq!(ids, vec!["tagged"]);
1349    }
1350
1351    // --- with_loaded_notes tests ---
1352
1353    #[test]
1354    fn with_loaded_notes_replaces_disk_version() {
1355        let dir = tempfile::tempdir().unwrap();
1356        let path = dir.path().join("note.md");
1357        write_note(&path, "disk content");
1358
1359        // Override with in-memory version that has different content.
1360        let mut in_memory = Note::from_path_with_body(&path).unwrap();
1361        in_memory.body = Some("in-memory content".to_string());
1362        let overrides: HashMap<PathBuf, Note> = [(path.clone(), in_memory)].into_iter().collect();
1363
1364        let notes = unwrap_notes(
1365            SearchQuery::new(dir.path())
1366                .and_content_contains("in-memory")
1367                .with_loaded_notes(&overrides)
1368                .execute()
1369                .unwrap(),
1370        );
1371        assert_eq!(notes.len(), 1);
1372
1373        // The disk version (with "disk content") should not appear.
1374        let disk_match = unwrap_notes(
1375            SearchQuery::new(dir.path())
1376                .and_content_contains("disk")
1377                .execute()
1378                .unwrap(),
1379        );
1380        assert_eq!(disk_match.len(), 1); // baseline: disk has "disk content"
1381
1382        let overrides2: HashMap<PathBuf, Note> = {
1383            let mut m2 = Note::from_path_with_body(&path).unwrap();
1384            m2.body = Some("in-memory content".to_string());
1385            [(path, m2)].into_iter().collect()
1386        };
1387        let no_disk_match = unwrap_notes(
1388            SearchQuery::new(dir.path())
1389                .and_content_contains("disk")
1390                .with_loaded_notes(&overrides2)
1391                .execute()
1392                .unwrap(),
1393        );
1394        assert!(no_disk_match.is_empty());
1395    }
1396
1397    #[test]
1398    fn with_loaded_notes_no_double_counting() {
1399        let dir = tempfile::tempdir().unwrap();
1400        write_note(&dir.path().join("a.md"), "Note A.");
1401        write_note(&dir.path().join("b.md"), "Note B.");
1402        write_note(&dir.path().join("c.md"), "Note C.");
1403
1404        let path_a = dir.path().join("a.md");
1405        let override_a = Note::from_path(&path_a).unwrap();
1406        let overrides: HashMap<PathBuf, Note> = [(path_a, override_a)].into_iter().collect();
1407
1408        let notes = unwrap_notes(
1409            SearchQuery::new(dir.path())
1410                .with_loaded_notes(&overrides)
1411                .execute()
1412                .unwrap(),
1413        );
1414        // Should still be exactly 3, not 4.
1415        assert_eq!(notes.len(), 3);
1416    }
1417
1418    #[test]
1419    fn with_loaded_notes_new_note_not_on_disk() {
1420        let dir = tempfile::tempdir().unwrap();
1421        write_note(&dir.path().join("existing.md"), "Existing note.");
1422
1423        // Create an in-memory note whose path does not exist on disk.
1424        let new_path = dir.path().join("new-unsaved.md");
1425        let new_note = Note::builder(&new_path)
1426            .unwrap()
1427            .body("Brand new content.")
1428            .build()
1429            .unwrap();
1430        let overrides: HashMap<PathBuf, Note> = [(new_path, new_note)].into_iter().collect();
1431
1432        let ids = sorted_ids(unwrap_notes(
1433            SearchQuery::new(dir.path())
1434                .with_loaded_notes(&overrides)
1435                .execute()
1436                .unwrap(),
1437        ));
1438        assert_eq!(ids, vec!["existing", "new-unsaved"]);
1439    }
1440
1441    #[test]
1442    fn with_loaded_notes_respects_tag_filter() {
1443        let dir = tempfile::tempdir().unwrap();
1444        let path = dir.path().join("note.md");
1445        write_note(&path, "---\ntags: [old-tag]\n---\nContent.");
1446
1447        // Override with a note that has a different tag.
1448        let mut override_note = Note::from_path(&path).unwrap();
1449        override_note.tags = vec![crate::LocatedTag {
1450            tag: "new-tag".to_string(),
1451            location: crate::Location::Frontmatter,
1452        }];
1453        let overrides: HashMap<PathBuf, Note> = [(path, override_note)].into_iter().collect();
1454
1455        // Should find the override note via the new tag.
1456        let ids = sorted_ids(unwrap_notes(
1457            SearchQuery::new(dir.path())
1458                .and_has_tag("new-tag")
1459                .with_loaded_notes(&overrides)
1460                .execute()
1461                .unwrap(),
1462        ));
1463        assert_eq!(ids, vec!["note"]);
1464
1465        // Should NOT find via the old tag (disk version is excluded).
1466        let ids_old = sorted_ids(unwrap_notes(
1467            SearchQuery::new(dir.path())
1468                .and_has_tag("old-tag")
1469                .with_loaded_notes(&overrides)
1470                .execute()
1471                .unwrap(),
1472        ));
1473        assert!(ids_old.is_empty());
1474    }
1475
1476    #[test]
1477    fn with_loaded_notes_glob_filter_applied_to_override() {
1478        let dir = tempfile::tempdir().unwrap();
1479        let subdir = dir.path().join("notes");
1480        write_note(&subdir.join("included.md"), "In notes/.");
1481
1482        // In-memory note at root level — should be excluded by the and_glob("notes/**") filter.
1483        let root_path = dir.path().join("outside.md");
1484        let outside_note = Note::builder(&root_path)
1485            .unwrap()
1486            .body("Outside notes dir.")
1487            .build()
1488            .unwrap();
1489        let overrides: HashMap<PathBuf, Note> = [(root_path, outside_note)].into_iter().collect();
1490
1491        let ids = sorted_ids(unwrap_notes(
1492            SearchQuery::new(dir.path())
1493                .and_glob("notes/**")
1494                .with_loaded_notes(&overrides)
1495                .execute()
1496                .unwrap(),
1497        ));
1498        assert_eq!(ids, vec!["included"]);
1499    }
1500
1501    #[test]
1502    fn with_loaded_notes_content_not_loaded_returns_error() {
1503        let dir = tempfile::tempdir().unwrap();
1504
1505        // In-memory note with no content loaded.
1506        let path = dir.path().join("no-content.md");
1507        let note = Note::builder(&path).unwrap().build().unwrap();
1508        let overrides: HashMap<PathBuf, Note> = [(path, note)].into_iter().collect();
1509
1510        let results = SearchQuery::new(dir.path())
1511            .and_content_contains("anything")
1512            .with_loaded_notes(&overrides)
1513            .execute()
1514            .unwrap();
1515
1516        assert_eq!(results.len(), 1);
1517        assert!(matches!(results[0], Err(NoteError::BodyNotLoaded)));
1518    }
1519
1520    #[test]
1521    fn with_loaded_notes_multiple_overrides() {
1522        let dir = tempfile::tempdir().unwrap();
1523        write_note(&dir.path().join("a.md"), "---\ntags: [old]\n---\nContent A.");
1524        write_note(&dir.path().join("b.md"), "---\ntags: [old]\n---\nContent B.");
1525        write_note(&dir.path().join("c.md"), "---\ntags: [old]\n---\nContent C.");
1526
1527        let path_a = dir.path().join("a.md");
1528        let path_b = dir.path().join("b.md");
1529
1530        let mut override_a = Note::from_path(&path_a).unwrap();
1531        override_a.tags = vec![crate::LocatedTag {
1532            tag: "new".to_string(),
1533            location: crate::Location::Frontmatter,
1534        }];
1535        let mut override_b = Note::from_path(&path_b).unwrap();
1536        override_b.tags = vec![crate::LocatedTag {
1537            tag: "new".to_string(),
1538            location: crate::Location::Frontmatter,
1539        }];
1540
1541        let overrides: HashMap<PathBuf, Note> = [(path_a, override_a), (path_b, override_b)].into_iter().collect();
1542
1543        // "new" tag should match both overrides but not "c" (which has "old" on disk).
1544        let ids = sorted_ids(unwrap_notes(
1545            SearchQuery::new(dir.path())
1546                .and_has_tag("new")
1547                .with_loaded_notes(&overrides)
1548                .execute()
1549                .unwrap(),
1550        ));
1551        assert_eq!(ids, vec!["a", "b"]);
1552    }
1553}