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
76pub struct SearchQuery<'a> {
96 config: SearchQueryConfig,
97 loaded_notes: Option<&'a HashMap<PathBuf, Note>>,
98}
99
100struct 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 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 pub fn and_glob(mut self, pattern: impl Into<String>) -> Self {
176 self.config.and_globs.push(pattern.into());
177 self
178 }
179
180 pub fn or_glob(mut self, pattern: impl Into<String>) -> Self {
182 self.config.or_globs.push(pattern.into());
183 self
184 }
185
186 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn and_links_to(mut self, note: Note) -> Self {
272 self.config.and_links_to.push(note);
273 self
274 }
275
276 pub fn or_links_to(mut self, note: Note) -> Self {
278 self.config.or_links_to.push(note);
279 self
280 }
281
282 pub fn case_sensitive(mut self) -> Self {
284 self.config.case_sensitivity = Some(CaseSensitivity::Sensitive);
285 self
286 }
287
288 pub fn ignore_case(mut self) -> Self {
290 self.config.case_sensitivity = Some(CaseSensitivity::Ignore);
291 self
292 }
293
294 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 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 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 let filter_note = |note: Note, rel: &Path| -> Option<Result<Note, NoteError>> {
463 if !has_filters {
464 return Some(Ok(note));
465 }
466
467 if let Some(ref expected_id) = and_id
471 && !strings_equal(
472 ¬e.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(<.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(¬e, n, &root).is_empty())
541 {
542 return None;
543 }
544
545 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(¬e.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(<.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(¬e, n, &root).is_empty())
613 {
614 return Some(Ok(note));
615 }
616
617 None
618 };
619
620 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 if let Some(notes) = loaded_notes {
640 for note in notes.values() {
641 if !and_globs.is_empty() {
643 let rel = note.path.strip_prefix(&root).unwrap_or(¬e.path);
644 if !and_glob_set.is_match(rel) {
645 continue;
646 }
647 }
648 if needs_content && note.body.is_none() {
650 results.push(Err(NoteError::BodyNotLoaded));
651 continue;
652 }
653 let rel_buf = note.path.strip_prefix(&root).unwrap_or(¬e.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
678pub 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
691pub 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
700pub 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 if let Some(notes) = loaded_notes {
729 for note in notes.values() {
730 for lt in ¬e.tags {
731 tags.insert(lt.tag.to_lowercase());
732 }
733 }
734 }
735
736 Ok(tags.into_iter().collect())
737}
738
739pub 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 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(<.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
797pub 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
806pub 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(¬e.path) {
831 results.push(Ok(note.clone()));
832 }
833 }
834 }
835
836 results
837}
838
839pub 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(¬e.path) {
861 results.push(Ok(note.clone()));
862 }
863 }
864 }
865
866 results
867}
868
869pub 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 #[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 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 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); 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 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 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 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 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 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 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 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 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}