1use std::sync::Arc;
16
17use grep_regex::RegexMatcherBuilder;
18use grep_searcher::sinks::UTF8;
19use grep_searcher::{BinaryDetection, SearcherBuilder};
20use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
21use nucleo_matcher::{Config, Matcher, Utf32Str};
22use serde::{Deserialize, Serialize};
23
24use crate::config::TagSource;
25use crate::errors::SearchError;
26use crate::repo::{MarkdownInfo, OtherFileInfo, Repo};
27use crate::vault::VaultPath;
28
29pub const DEFAULT_RESULT_LIMIT: usize = 50;
31
32const MAX_SNIPPET_LENGTH: usize = 200;
34
35const CONTENT_CONTEXT_LINES: usize = 1;
37
38const FACET_MATCH_BASE_SCORE: u32 = 100;
40
41const MAX_CONTENT_MATCH_SCORE: u32 = 100;
43
44const MAX_TEXT_SAMPLE_BYTES: usize = 5000;
46
47#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ParsedQuery {
54 pub terms: Vec<String>,
56 pub facets: Vec<(String, String)>,
58}
59
60impl ParsedQuery {
61 pub fn is_empty(&self) -> bool {
63 self.terms.is_empty() && self.facets.is_empty()
64 }
65}
66
67pub fn parse_query(q: &str) -> ParsedQuery {
85 let mut terms = Vec::new();
86 let mut facets = Vec::new();
87
88 for token in query_tokens(q) {
89 if let Some(colon_pos) = token.find(':') {
91 if token.contains("://") {
93 terms.push(token.to_string());
94 continue;
95 }
96
97 if colon_pos == 0 || colon_pos == token.len() - 1 {
99 terms.push(token.to_string());
100 continue;
101 }
102
103 let (key, value) = token.split_at(colon_pos);
104 let value = &value[1..]; let value = value
109 .strip_prefix('"')
110 .map(|v| v.strip_suffix('"').unwrap_or(v))
111 .unwrap_or(value);
112
113 if !key.is_empty() && !value.is_empty() {
115 facets.push((key.to_string(), value.to_string()));
116 } else {
117 terms.push(token.to_string());
118 }
119 } else {
120 terms.push(token.to_string());
121 }
122 }
123
124 ParsedQuery { terms, facets }
125}
126
127fn query_tokens(q: &str) -> Vec<&str> {
136 let mut tokens = Vec::new();
137 let mut rest = q.trim_start();
138 while !rest.is_empty() {
139 let next_space = |s: &str| s.find(char::is_whitespace).unwrap_or(s.len());
140 let mut end = next_space(rest);
141 if let Some(open) = rest[..end].find(":\"") {
142 let key = &rest[..open];
143 let value_start = open + 2;
144 if !key.is_empty()
145 && !key.contains([':', '"'])
146 && let Some(close) = rest[value_start..].find('"')
147 {
148 let after = value_start + close + 1;
149 end = after + next_space(&rest[after..]);
150 }
151 }
152 tokens.push(&rest[..end]);
153 rest = rest[end..].trim_start();
154 }
155 tokens
156}
157
158fn facet_matches(
161 frontmatter: Option<&crate::markdown::SimpleMetadata>,
162 field: &str,
163 value: &str,
164) -> bool {
165 match frontmatter.and_then(|fm| fm.get(field)) {
166 Some(serde_json::Value::String(s)) => s.to_lowercase().contains(&value.to_lowercase()),
167 Some(serde_json::Value::Array(arr)) => arr.iter().any(|v| {
168 v.as_str()
169 .map(|s| s.to_lowercase().contains(&value.to_lowercase()))
170 .unwrap_or(false)
171 }),
172 _ => false,
173 }
174}
175
176fn build_snippet(line_num: u64, line: &str) -> String {
183 let mut s = format!("Line {}: ", line_num);
184 if line.len() > MAX_SNIPPET_LENGTH {
185 let end = line.floor_char_boundary(MAX_SNIPPET_LENGTH);
186 s.push_str(&line[..end]);
187 s.push_str("...");
188 } else {
189 s.push_str(line);
190 }
191 s
192}
193
194fn field_weight(field: &str) -> u32 {
196 match field {
197 "title" => 3,
198 "tags" | "keywords" | "categories" | "category" | "aliases" | "alias" => 2,
200 "description" | "summary" => 1,
201 _ => 1, }
203}
204
205#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
207#[serde(rename_all = "lowercase")]
208pub enum FolderScope {
209 Current,
211 #[default]
213 Everywhere,
214}
215
216#[derive(Debug, Clone, Deserialize)]
218pub struct SearchQuery {
219 pub q: String,
221
222 #[serde(default = "default_limit")]
224 pub limit: usize,
225
226 #[serde(default = "default_scope")]
228 pub scope: SearchScope,
229
230 #[serde(default)]
232 pub filetype: Option<String>,
233
234 #[serde(default)]
236 pub folder: Option<String>,
237
238 #[serde(default)]
240 pub folder_scope: FolderScope,
241}
242
243fn default_limit() -> usize {
244 DEFAULT_RESULT_LIMIT
245}
246
247fn default_scope() -> SearchScope {
248 SearchScope::All
249}
250
251#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
253#[serde(rename_all = "lowercase")]
254pub enum SearchScope {
255 Metadata,
257 Content,
259 #[default]
261 All,
262}
263
264#[derive(Debug, Clone, Serialize)]
266pub struct SearchResult {
267 pub url_path: String,
269
270 pub title: Option<String>,
272
273 pub description: Option<String>,
275
276 pub tags: Option<String>,
278
279 pub score: u32,
281
282 pub snippet: Option<String>,
284
285 pub is_content_match: bool,
287
288 pub filetype: String,
290}
291
292#[derive(Debug, Clone, Serialize)]
294pub struct SearchResponse {
295 pub query: String,
297
298 pub total_matches: usize,
300
301 pub results: Vec<SearchResult>,
303
304 pub duration_ms: u64,
306
307 #[serde(skip_serializing_if = "std::ops::Not::not")]
309 pub scan_in_progress: bool,
310
311 #[serde(skip_serializing_if = "is_zero")]
315 pub not_downloaded: usize,
316}
317
318fn is_zero(count: &usize) -> bool {
319 *count == 0
320}
321
322struct ContentHits {
325 results: Vec<SearchResult>,
326 not_downloaded: usize,
327}
328
329pub struct SearchEngine {
334 repo: Arc<Repo>,
335}
336
337impl SearchEngine {
338 pub fn new(repo: Arc<Repo>) -> Self {
340 Self { repo }
341 }
342
343 pub fn search(&self, query: &SearchQuery) -> Result<SearchResponse, SearchError> {
345 let start = std::time::Instant::now();
346
347 let parsed = parse_query(&query.q);
349
350 if parsed.is_empty() {
352 return Ok(SearchResponse {
353 query: query.q.clone(),
354 total_matches: 0,
355 results: Vec::new(),
356 duration_ms: 0,
357 scan_in_progress: false,
358 not_downloaded: 0,
359 });
360 }
361
362 let mut all_results = Vec::new();
363 let mut not_downloaded = 0;
364
365 if query.scope != SearchScope::Content {
367 all_results.extend(self.search_metadata(query, &parsed)?);
368 all_results.extend(self.search_tag_pages(query, &parsed));
369 }
370
371 if query.scope != SearchScope::Metadata && !parsed.terms.is_empty() {
373 let content = self.search_content(query, &parsed)?;
374 not_downloaded = content.not_downloaded;
375 all_results.extend(content.results);
376 }
377
378 if self.should_search_other_files(&query.filetype) {
380 all_results.extend(self.search_other_files_metadata(query, &parsed)?);
381 }
382
383 all_results = Self::deduplicate_results(all_results);
385
386 all_results.sort_by_key(|r| std::cmp::Reverse(r.score));
388
389 let total_matches = all_results.len();
390
391 all_results.truncate(query.limit);
393
394 let duration_ms = start.elapsed().as_millis() as u64;
395
396 Ok(SearchResponse {
397 query: query.q.clone(),
398 total_matches,
399 results: all_results,
400 duration_ms,
401 scan_in_progress: false,
402 not_downloaded,
403 })
404 }
405
406 fn search_metadata(
408 &self,
409 query: &SearchQuery,
410 parsed: &ParsedQuery,
411 ) -> Result<Vec<SearchResult>, SearchError> {
412 let pattern = if parsed.terms.is_empty() {
414 None
415 } else {
416 Some(Pattern::parse(
417 &parsed.terms.join(" "),
418 CaseMatching::Ignore,
419 Normalization::Smart,
420 ))
421 };
422
423 let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
426
427 let guard = self.repo.markdown_files.pin();
433 let results: Vec<SearchResult> = guard
434 .iter()
435 .map(|(_, info)| info)
436 .filter(|info| self.matches_folder_filter(info, &query.folder, &query.folder_scope))
437 .filter(|info| self.matches_filetype_filter(info, &query.filetype, true))
438 .filter(|info| {
440 parsed
441 .facets
442 .iter()
443 .all(|(field, value)| facet_matches(info.frontmatter.as_ref(), field, value))
444 })
445 .filter_map(|info| self.match_metadata(pattern.as_ref(), info, &mut matcher))
446 .collect();
447
448 Ok(results)
449 }
450
451 fn search_tag_pages(&self, query: &SearchQuery, parsed: &ParsedQuery) -> Vec<SearchResult> {
456 if query.folder_scope == FolderScope::Current && query.folder.is_some() {
458 return Vec::new();
459 }
460
461 let pattern = if parsed.terms.is_empty() {
462 None
463 } else {
464 Some(Pattern::parse(
465 &parsed.terms.join(" "),
466 CaseMatching::Ignore,
467 Normalization::Smart,
468 ))
469 };
470
471 let tag_sources = self.repo.tag_sources();
472 let tag_index = &self.repo.tag_index;
473
474 let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
475 let mut results = Vec::new();
476
477 for source in tag_sources {
478 let url_source = source.url_source();
479 let label = source.singular_label();
480 let all_tags = tag_index.get_all_tags(&source.field);
481
482 for tag_info in &all_tags {
483 let score = self.score_tag_page(
484 pattern.as_ref(),
485 parsed,
486 source,
487 &label,
488 tag_info,
489 &mut matcher,
490 );
491
492 if let Some(score) = score {
493 let page_word = if tag_info.count == 1 { "page" } else { "pages" };
494 results.push(SearchResult {
495 url_path: format!("/{}/{}/", url_source, tag_info.normalized),
496 title: Some(format!("{}: {}", label, tag_info.display)),
497 description: Some(format!("{} {}", tag_info.count, page_word)),
498 tags: None,
499 score,
500 snippet: None,
501 is_content_match: false,
502 filetype: "tag".to_string(),
503 });
504 }
505 }
506 }
507
508 results
509 }
510
511 fn score_tag_page(
515 &self,
516 pattern: Option<&Pattern>,
517 parsed: &ParsedQuery,
518 source: &TagSource,
519 label: &str,
520 tag_info: &crate::tag_index::TagInfo,
521 matcher: &mut Matcher,
522 ) -> Option<u32> {
523 if pattern.is_none() {
525 let matches_facet = parsed.facets.iter().any(|(field, value)| {
526 field.to_lowercase() == source.field.to_lowercase()
527 && tag_info
528 .display
529 .to_lowercase()
530 .contains(&value.to_lowercase())
531 });
532 return matches_facet.then_some(FACET_MATCH_BASE_SCORE);
533 }
534
535 let pattern = pattern?;
536 let mut best_score: u32 = 0;
537
538 if let Some(score) = self.fuzzy_match(pattern, &tag_info.display, matcher) {
540 best_score = best_score.max(score.saturating_mul(2));
541 }
542
543 let labeled = format!("{}: {}", label, tag_info.display);
545 if let Some(score) = self.fuzzy_match(pattern, &labeled, matcher) {
546 best_score = best_score.max(score.saturating_mul(2));
547 }
548
549 (best_score > 0).then_some(best_score)
550 }
551
552 fn match_metadata(
556 &self,
557 pattern: Option<&Pattern>,
558 info: &MarkdownInfo,
559 matcher: &mut Matcher,
560 ) -> Option<SearchResult> {
561 let extract_string = |fm: &crate::markdown::SimpleMetadata, key: &str| -> Option<String> {
563 fm.get(key).and_then(|v| match v {
564 serde_json::Value::String(s) => Some(s.clone()),
565 serde_json::Value::Array(arr) => {
566 let strings: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
567 if strings.is_empty() {
568 None
569 } else {
570 Some(strings.join(", "))
571 }
572 }
573 _ => None,
574 })
575 };
576
577 let Some(pattern) = pattern else {
579 return Some(SearchResult {
580 url_path: info.url_path.clone(),
581 title: info
582 .frontmatter
583 .as_ref()
584 .and_then(|fm| extract_string(fm, "title")),
585 description: info
586 .frontmatter
587 .as_ref()
588 .and_then(|fm| extract_string(fm, "description")),
589 tags: info
590 .frontmatter
591 .as_ref()
592 .and_then(|fm| extract_string(fm, "tags")),
593 score: FACET_MATCH_BASE_SCORE,
594 snippet: None,
595 is_content_match: false,
596 filetype: "markdown".to_string(),
597 });
598 };
599
600 let mut best_score: u32 = 0;
601
602 if let Some(score) = self.fuzzy_match(pattern, &info.url_path, matcher) {
604 best_score = best_score.max(score.saturating_mul(2));
605 }
606
607 if let Some(filename) = info.raw_path.file_stem().and_then(|s| s.to_str())
609 && let Some(score) = self.fuzzy_match(pattern, filename, matcher)
610 {
611 best_score = best_score.max(score.saturating_mul(2));
612 }
613
614 if let Some(ref fm) = info.frontmatter {
616 for (key, value) in fm.iter() {
617 match value {
619 serde_json::Value::String(s) => {
620 if let Some(score) = self.fuzzy_match(pattern, s, matcher) {
621 let weight = field_weight(key);
622 best_score = best_score.max(score.saturating_mul(weight));
623 }
624 }
625 serde_json::Value::Array(arr) => {
626 for item in arr {
627 if let Some(s) = item.as_str()
628 && let Some(score) = self.fuzzy_match(pattern, s, matcher)
629 {
630 let weight = field_weight(key);
631 best_score = best_score.max(score.saturating_mul(weight));
632 }
633 }
634 }
635 _ => {}
636 }
637 }
638 }
639
640 if best_score > 0 {
641 Some(SearchResult {
642 url_path: info.url_path.clone(),
643 title: info
644 .frontmatter
645 .as_ref()
646 .and_then(|fm| extract_string(fm, "title")),
647 description: info
648 .frontmatter
649 .as_ref()
650 .and_then(|fm| extract_string(fm, "description")),
651 tags: info
652 .frontmatter
653 .as_ref()
654 .and_then(|fm| extract_string(fm, "tags")),
655 score: best_score,
656 snippet: None,
657 is_content_match: false,
658 filetype: "markdown".to_string(),
659 })
660 } else {
661 None
662 }
663 }
664
665 fn fuzzy_match(&self, pattern: &Pattern, haystack: &str, matcher: &mut Matcher) -> Option<u32> {
667 let mut buf = Vec::new();
668 let haystack = Utf32Str::new(haystack, &mut buf);
669 pattern.score(haystack, matcher)
670 }
671
672 fn search_content(
674 &self,
675 query: &SearchQuery,
676 parsed: &ParsedQuery,
677 ) -> Result<ContentHits, SearchError> {
678 let search_terms = parsed.terms.join(" ");
680 let regex_pattern = regex::escape(&search_terms);
681 let matcher = RegexMatcherBuilder::new()
682 .case_insensitive(true)
683 .build(®ex_pattern)
684 .map_err(|e| SearchError::PatternInvalid {
685 pattern: search_terms,
686 reason: e.to_string(),
687 })?;
688
689 let files: Vec<_> =
691 self.repo
692 .markdown_files
693 .pin()
694 .iter()
695 .filter(|(_, info)| {
696 self.matches_folder_filter(info, &query.folder, &query.folder_scope)
697 })
698 .filter(|(_, info)| self.matches_filetype_filter(info, &query.filetype, true))
699 .filter(|(_, info)| {
701 parsed.facets.iter().all(|(field, value)| {
702 facet_matches(info.frontmatter.as_ref(), field, value)
703 })
704 })
705 .map(|(_, info)| info.clone())
706 .collect();
707
708 let mut searcher = SearcherBuilder::new()
713 .binary_detection(BinaryDetection::quit(0x00))
714 .line_number(true)
715 .before_context(CONTENT_CONTEXT_LINES)
716 .after_context(CONTENT_CONTEXT_LINES)
717 .build();
718
719 let mut not_downloaded = 0;
724 let results: Vec<SearchResult> = files
725 .into_iter()
726 .filter_map(|info| {
727 self.search_file_content(&mut searcher, &matcher, &info, &mut not_downloaded)
728 .ok()
729 .flatten()
730 })
731 .collect();
732
733 Ok(ContentHits {
734 results,
735 not_downloaded,
736 })
737 }
738
739 fn search_file_content(
744 &self,
745 searcher: &mut grep_searcher::Searcher,
746 matcher: &grep_regex::RegexMatcher,
747 info: &MarkdownInfo,
748 not_downloaded: &mut usize,
749 ) -> Result<Option<SearchResult>, SearchError> {
750 let vault = self.repo.vault();
754 let Ok(path) = VaultPath::from_relative_native(&info.raw_path) else {
755 return Ok(None);
756 };
757
758 let mut matches: Vec<(u64, String)> = Vec::new();
759 let mut match_count = 0u32;
760 let sink = UTF8(|line_num, line| {
761 match_count += 1;
762 if matches.len() < 3 {
763 matches.push((line_num, line.trim().to_string()));
765 }
766 Ok(true)
767 });
768
769 let search_result = match vault.local_path(&path) {
777 Some(local) => {
778 if !local.exists() {
779 return Ok(None);
780 }
781 searcher.search_path(matcher, &local, sink)
782 }
783 None => match vault.read(&path) {
784 Ok(bytes) => searcher.search_slice(matcher, &bytes, sink),
785 Err(e) => {
786 if matches!(e, crate::vault::VaultError::NotDownloaded { .. }) {
787 *not_downloaded += 1;
788 }
789 tracing::debug!("Search skipped {path}: {e}");
790 return Ok(None);
791 }
792 },
793 };
794
795 if let Err(e) = search_result {
796 if e.kind() == std::io::ErrorKind::Deadlock {
797 *not_downloaded += 1;
798 }
799 tracing::debug!("Search error in {}: {}", path, e);
800 return Ok(None);
801 }
802
803 if match_count > 0 {
804 let snippet = matches
806 .first()
807 .map(|(line_num, line)| build_snippet(*line_num, line));
808
809 let score = match_count.min(MAX_CONTENT_MATCH_SCORE);
812
813 let extract_string = |fm: &crate::markdown::SimpleMetadata,
815 key: &str|
816 -> Option<String> {
817 fm.get(key).and_then(|v| match v {
818 serde_json::Value::String(s) => Some(s.clone()),
819 serde_json::Value::Array(arr) => {
820 let strings: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
821 if strings.is_empty() {
822 None
823 } else {
824 Some(strings.join(", "))
825 }
826 }
827 _ => None,
828 })
829 };
830
831 Ok(Some(SearchResult {
832 url_path: info.url_path.clone(),
833 title: info
834 .frontmatter
835 .as_ref()
836 .and_then(|fm| extract_string(fm, "title")),
837 description: info
838 .frontmatter
839 .as_ref()
840 .and_then(|fm| extract_string(fm, "description")),
841 tags: info
842 .frontmatter
843 .as_ref()
844 .and_then(|fm| extract_string(fm, "tags")),
845 score,
846 snippet,
847 is_content_match: true,
848 filetype: "markdown".to_string(),
849 }))
850 } else {
851 Ok(None)
852 }
853 }
854
855 fn matches_folder_filter(
857 &self,
858 info: &MarkdownInfo,
859 folder: &Option<String>,
860 folder_scope: &FolderScope,
861 ) -> bool {
862 if *folder_scope == FolderScope::Everywhere {
864 return true;
865 }
866
867 match folder {
869 Some(folder_path) => {
870 let normalized = if folder_path.starts_with('/') {
871 folder_path.clone()
872 } else {
873 format!("/{}", folder_path)
874 };
875 info.url_path.starts_with(&normalized)
876 }
877 None => true, }
879 }
880
881 fn matches_filetype_filter(
883 &self,
884 _info: &MarkdownInfo,
885 filetype: &Option<String>,
886 is_markdown: bool,
887 ) -> bool {
888 match filetype {
889 Some(ft) => {
890 let ft_lower = ft.to_lowercase();
891 match ft_lower.as_str() {
892 "markdown" | "md" => is_markdown,
893 "all" => true,
894 _ => false,
895 }
896 }
897 None => true,
898 }
899 }
900
901 fn should_search_other_files(&self, filetype: &Option<String>) -> bool {
903 filetype
904 .as_ref()
905 .map(|ft| ft.to_lowercase() == "all")
906 .unwrap_or(false)
907 }
908
909 fn search_other_files_metadata(
911 &self,
912 query: &SearchQuery,
913 parsed: &ParsedQuery,
914 ) -> Result<Vec<SearchResult>, SearchError> {
915 let pattern = if parsed.terms.is_empty() {
917 return Ok(Vec::new());
919 } else {
920 Pattern::parse(
921 &parsed.terms.join(" "),
922 CaseMatching::Ignore,
923 Normalization::Smart,
924 )
925 };
926
927 let files: Vec<_> = self
929 .repo
930 .other_files
931 .pin()
932 .iter()
933 .filter(|(_, info)| info.is_searchable())
934 .filter(|(_, info)| {
935 self.matches_other_file_folder_filter(info, &query.folder, &query.folder_scope)
936 })
937 .map(|(_, info)| info.clone())
938 .collect();
939
940 let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
942 let results: Vec<SearchResult> = files
943 .into_iter()
944 .filter_map(|info| self.match_other_file(&pattern, &info, &mut matcher))
945 .collect();
946
947 Ok(results)
948 }
949
950 fn match_other_file(
952 &self,
953 pattern: &Pattern,
954 info: &OtherFileInfo,
955 matcher: &mut Matcher,
956 ) -> Option<SearchResult> {
957 let mut best_score: u32 = 0;
958
959 if let Some(score) = self.fuzzy_match(pattern, &info.url_path, matcher) {
961 best_score = best_score.max(score.saturating_mul(2));
962 }
963
964 if let Some(filename) = info.raw_path.file_stem().and_then(|s| s.to_str())
966 && let Some(score) = self.fuzzy_match(pattern, filename, matcher)
967 {
968 best_score = best_score.max(score.saturating_mul(2));
969 }
970
971 if let Some(ref text) = info.extracted_text {
973 let sample = if text.len() > MAX_TEXT_SAMPLE_BYTES {
976 &text[..text.floor_char_boundary(MAX_TEXT_SAMPLE_BYTES)]
977 } else {
978 text.as_str()
979 };
980 if let Some(score) = self.fuzzy_match(pattern, sample, matcher) {
981 best_score = best_score.max(score);
982 }
983 }
984
985 if best_score > 0 {
986 let snippet = info.extracted_text.as_ref().map(|text| {
988 if text.len() > MAX_SNIPPET_LENGTH {
989 let end = text.floor_char_boundary(MAX_SNIPPET_LENGTH);
991 format!("{}...", &text[..end])
992 } else {
993 text.clone()
994 }
995 });
996
997 Some(SearchResult {
998 url_path: info.url_path.clone(),
999 title: info
1000 .raw_path
1001 .file_name()
1002 .and_then(|n| n.to_str())
1003 .map(|s| s.to_string()),
1004 description: None,
1005 tags: None,
1006 score: best_score,
1007 snippet,
1008 is_content_match: info.extracted_text.is_some(),
1009 filetype: info.filetype().to_string(),
1010 })
1011 } else {
1012 None
1013 }
1014 }
1015
1016 fn matches_other_file_folder_filter(
1018 &self,
1019 info: &OtherFileInfo,
1020 folder: &Option<String>,
1021 folder_scope: &FolderScope,
1022 ) -> bool {
1023 if *folder_scope == FolderScope::Everywhere {
1024 return true;
1025 }
1026
1027 match folder {
1028 Some(folder_path) => {
1029 let normalized = if folder_path.starts_with('/') {
1030 folder_path.clone()
1031 } else {
1032 format!("/{}", folder_path)
1033 };
1034 info.url_path.starts_with(&normalized)
1035 }
1036 None => true,
1037 }
1038 }
1039
1040 fn deduplicate_results(mut results: Vec<SearchResult>) -> Vec<SearchResult> {
1042 results.sort_by(|a, b| {
1044 a.url_path
1045 .cmp(&b.url_path)
1046 .then_with(|| b.score.cmp(&a.score))
1047 });
1048
1049 let mut seen = std::collections::HashMap::new();
1051 for result in results {
1052 seen.entry(result.url_path.clone())
1053 .and_modify(|existing: &mut SearchResult| {
1054 if result.score > existing.score {
1056 existing.score = result.score;
1057 }
1058 if result.is_content_match && result.snippet.is_some() {
1060 existing.snippet = result.snippet.clone();
1061 existing.is_content_match = true;
1062 }
1063 })
1064 .or_insert(result);
1065 }
1066
1067 seen.into_values().collect()
1068 }
1069}
1070
1071pub fn search_other_files(
1074 repo: &Repo,
1075 query: &str,
1076 folder: Option<&str>,
1077 filetype: Option<&str>,
1078 limit: usize,
1079) -> Vec<SearchResult> {
1080 let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
1081
1082 let files: Vec<_> = repo
1083 .other_files
1084 .pin()
1085 .iter()
1086 .filter(|(_, info)| {
1087 if let Some(folder_path) = folder {
1089 let normalized = if folder_path.starts_with('/') {
1090 folder_path.to_string()
1091 } else {
1092 format!("/{}", folder_path)
1093 };
1094 if !info.url_path.starts_with(&normalized) {
1095 return false;
1096 }
1097 }
1098
1099 if let Some(ft) = filetype {
1101 let ft_lower = ft.to_lowercase();
1102 if ft_lower != "all" {
1103 let ext = info
1104 .raw_path
1105 .extension()
1106 .and_then(|e| e.to_str())
1107 .unwrap_or("")
1108 .to_lowercase();
1109 let matches = match ft_lower.as_str() {
1110 "pdf" => ext == "pdf",
1111 "image" => matches!(
1112 ext.as_str(),
1113 "jpg" | "jpeg" | "png" | "gif" | "webp" | "svg"
1114 ),
1115 "video" => matches!(ext.as_str(), "mp4" | "webm" | "mov" | "avi"),
1116 "audio" => matches!(ext.as_str(), "mp3" | "wav" | "ogg" | "m4a" | "flac"),
1117 _ => true,
1118 };
1119 if !matches {
1120 return false;
1121 }
1122 }
1123 }
1124
1125 true
1126 })
1127 .map(|(_, info)| info.clone())
1128 .collect();
1129
1130 let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
1132 let mut results: Vec<SearchResult> = files
1133 .into_iter()
1134 .filter_map(|info| {
1135 let mut buf = Vec::new();
1136 let haystack = Utf32Str::new(&info.url_path, &mut buf);
1137
1138 pattern.score(haystack, &mut matcher).map(|score| {
1139 let ext = info
1140 .raw_path
1141 .extension()
1142 .and_then(|e| e.to_str())
1143 .unwrap_or("")
1144 .to_lowercase();
1145
1146 let filetype = match ext.as_str() {
1147 "pdf" => "pdf",
1148 "jpg" | "jpeg" | "png" | "gif" | "webp" | "svg" => "image",
1149 "mp4" | "webm" | "mov" | "avi" => "video",
1150 "mp3" | "wav" | "ogg" | "m4a" | "flac" => "audio",
1151 _ => "other",
1152 };
1153
1154 SearchResult {
1155 url_path: info.url_path.clone(),
1156 title: None,
1157 description: None,
1158 tags: None,
1159 score,
1160 snippet: None,
1161 is_content_match: false,
1162 filetype: filetype.to_string(),
1163 }
1164 })
1165 })
1166 .collect();
1167
1168 results.sort_by_key(|r| std::cmp::Reverse(r.score));
1170 results.truncate(limit);
1171
1172 results
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177 use super::*;
1178
1179 #[test]
1180 fn test_search_query_defaults() {
1181 let json = r#"{"q": "test"}"#;
1182 let query: SearchQuery = serde_json::from_str(json).unwrap();
1183
1184 assert_eq!(query.q, "test");
1185 assert_eq!(query.limit, DEFAULT_RESULT_LIMIT);
1186 assert_eq!(query.scope, SearchScope::All);
1187 assert!(query.filetype.is_none());
1188 assert!(query.folder.is_none());
1189 }
1190
1191 #[test]
1192 fn test_search_query_with_options() {
1193 let json = r#"{"q": "test", "limit": 10, "scope": "metadata", "folder": "/docs"}"#;
1194 let query: SearchQuery = serde_json::from_str(json).unwrap();
1195
1196 assert_eq!(query.q, "test");
1197 assert_eq!(query.limit, 10);
1198 assert_eq!(query.scope, SearchScope::Metadata);
1199 assert_eq!(query.folder, Some("/docs".to_string()));
1200 }
1201
1202 #[test]
1203 fn test_search_scope_deserialization() {
1204 assert_eq!(
1205 serde_json::from_str::<SearchScope>(r#""metadata""#).unwrap(),
1206 SearchScope::Metadata
1207 );
1208 assert_eq!(
1209 serde_json::from_str::<SearchScope>(r#""content""#).unwrap(),
1210 SearchScope::Content
1211 );
1212 assert_eq!(
1213 serde_json::from_str::<SearchScope>(r#""all""#).unwrap(),
1214 SearchScope::All
1215 );
1216 }
1217
1218 #[test]
1219 fn test_deduplicate_results() {
1220 let results = vec![
1221 SearchResult {
1222 url_path: "/test/".to_string(),
1223 title: Some("Test".to_string()),
1224 description: None,
1225 tags: None,
1226 score: 50,
1227 snippet: None,
1228 is_content_match: false,
1229 filetype: "markdown".to_string(),
1230 },
1231 SearchResult {
1232 url_path: "/test/".to_string(),
1233 title: Some("Test".to_string()),
1234 description: None,
1235 tags: None,
1236 score: 100,
1237 snippet: Some("content match".to_string()),
1238 is_content_match: true,
1239 filetype: "markdown".to_string(),
1240 },
1241 ];
1242
1243 let deduped = SearchEngine::deduplicate_results(results);
1244 assert_eq!(deduped.len(), 1);
1245
1246 let result = &deduped[0];
1247 assert_eq!(result.score, 100); assert!(result.snippet.is_some()); }
1250
1251 #[test]
1254 fn test_parse_query_simple_terms() {
1255 let parsed = parse_query("rust async");
1256 assert_eq!(parsed.terms, vec!["rust", "async"]);
1257 assert!(parsed.facets.is_empty());
1258 }
1259
1260 #[test]
1261 fn test_parse_query_single_facet() {
1262 let parsed = parse_query("category:rust");
1263 assert!(parsed.terms.is_empty());
1264 assert_eq!(
1265 parsed.facets,
1266 vec![("category".to_string(), "rust".to_string())]
1267 );
1268 }
1269
1270 #[test]
1271 fn test_parse_query_mixed_terms_and_facets() {
1272 let parsed = parse_query("guide category:programming author:alice");
1273 assert_eq!(parsed.terms, vec!["guide"]);
1274 assert_eq!(
1275 parsed.facets,
1276 vec![
1277 ("category".to_string(), "programming".to_string()),
1278 ("author".to_string(), "alice".to_string()),
1279 ]
1280 );
1281 }
1282
1283 #[test]
1284 fn test_parse_query_url_not_facet() {
1285 let parsed = parse_query("check https://example.com");
1286 assert_eq!(parsed.terms, vec!["check", "https://example.com"]);
1287 assert!(parsed.facets.is_empty());
1288 }
1289
1290 #[test]
1291 fn test_parse_query_http_url_not_facet() {
1292 let parsed = parse_query("link http://example.com/path");
1293 assert_eq!(parsed.terms, vec!["link", "http://example.com/path"]);
1294 assert!(parsed.facets.is_empty());
1295 }
1296
1297 #[test]
1298 fn test_parse_query_colon_at_start_not_facet() {
1299 let parsed = parse_query(":value");
1300 assert_eq!(parsed.terms, vec![":value"]);
1301 assert!(parsed.facets.is_empty());
1302 }
1303
1304 #[test]
1305 fn test_parse_query_colon_at_end_not_facet() {
1306 let parsed = parse_query("key:");
1307 assert_eq!(parsed.terms, vec!["key:"]);
1308 assert!(parsed.facets.is_empty());
1309 }
1310
1311 #[test]
1312 fn test_parse_query_quoted_facet_value_keeps_spaces() {
1313 let parsed = parse_query(r#"standup type:"Meeting Notes" author:al"#);
1314 assert_eq!(parsed.terms, vec!["standup"]);
1315 assert_eq!(
1316 parsed.facets,
1317 vec![
1318 ("type".to_string(), "Meeting Notes".to_string()),
1319 ("author".to_string(), "al".to_string()),
1320 ]
1321 );
1322 }
1323
1324 #[test]
1325 fn test_parse_query_quoted_single_word_facet() {
1326 let parsed = parse_query(r#"type:"person""#);
1327 assert_eq!(
1328 parsed.facets,
1329 vec![("type".to_string(), "person".to_string())]
1330 );
1331 }
1332
1333 #[test]
1334 fn test_parse_query_unclosed_quote_splits_on_whitespace() {
1335 let parsed = parse_query(r#"type:"Meeting No"#);
1337 assert_eq!(
1338 parsed.facets,
1339 vec![("type".to_string(), "Meeting".to_string())]
1340 );
1341 assert_eq!(parsed.terms, vec!["No"]);
1342 }
1343
1344 #[test]
1345 fn test_parse_query_stray_quote_in_term_does_not_group() {
1346 let parsed = parse_query(r#""hello world" type:x"#);
1347 assert_eq!(parsed.terms, vec![r#""hello"#, r#"world""#]);
1348 assert_eq!(parsed.facets, vec![("type".to_string(), "x".to_string())]);
1349 }
1350
1351 #[test]
1352 fn test_parse_query_empty_quoted_value_is_a_term() {
1353 let parsed = parse_query(r#"type:"""#);
1354 assert!(parsed.facets.is_empty());
1355 assert_eq!(parsed.terms, vec![r#"type:"""#]);
1356 }
1357
1358 #[test]
1359 fn test_facet_matches_wikilink_company() {
1360 let mut fm = crate::markdown::SimpleMetadata::new();
1361 fm.insert(
1362 "company".to_string(),
1363 serde_json::Value::String("[[Acme Corp]]".to_string()),
1364 );
1365 assert!(facet_matches(Some(&fm), "company", "acme"));
1366 assert!(!facet_matches(Some(&fm), "company", "globex"));
1367 }
1368
1369 proptest::proptest! {
1370 #[test]
1373 fn prop_parse_query_never_panics(q in "\\PC{0,64}") {
1374 let parsed = parse_query(&q);
1375 let tokens = query_tokens(&q);
1376 proptest::prop_assert_eq!(tokens.len(), parsed.terms.len() + parsed.facets.len());
1377 }
1378
1379 #[test]
1381 fn prop_quoted_facet_round_trips(
1382 key in "[a-z_]{1,10}",
1383 value in "[A-Za-z0-9][A-Za-z0-9 ]{0,20}[A-Za-z0-9]",
1384 ) {
1385 let parsed = parse_query(&format!("lead {key}:\"{value}\" tail"));
1386 proptest::prop_assert_eq!(parsed.facets, vec![(key, value)]);
1387 proptest::prop_assert_eq!(parsed.terms, vec!["lead".to_string(), "tail".to_string()]);
1388 }
1389 }
1390
1391 #[test]
1392 fn test_parse_query_empty() {
1393 let parsed = parse_query("");
1394 assert!(parsed.is_empty());
1395 }
1396
1397 #[test]
1398 fn test_parse_query_whitespace_only() {
1399 let parsed = parse_query(" ");
1400 assert!(parsed.is_empty());
1401 }
1402
1403 #[test]
1404 fn test_parsed_query_is_empty() {
1405 assert!(
1406 ParsedQuery {
1407 terms: vec![],
1408 facets: vec![]
1409 }
1410 .is_empty()
1411 );
1412 assert!(
1413 !ParsedQuery {
1414 terms: vec!["test".to_string()],
1415 facets: vec![]
1416 }
1417 .is_empty()
1418 );
1419 assert!(
1420 !ParsedQuery {
1421 terms: vec![],
1422 facets: vec![("k".to_string(), "v".to_string())]
1423 }
1424 .is_empty()
1425 );
1426 }
1427
1428 #[test]
1431 fn test_facet_matches_exact() {
1432 let mut fm = crate::markdown::SimpleMetadata::new();
1433 fm.insert(
1434 "category".to_string(),
1435 serde_json::Value::String("programming".to_string()),
1436 );
1437
1438 assert!(facet_matches(Some(&fm), "category", "programming"));
1439 }
1440
1441 #[test]
1442 fn test_facet_matches_contains() {
1443 let mut fm = crate::markdown::SimpleMetadata::new();
1444 fm.insert(
1445 "category".to_string(),
1446 serde_json::Value::String("systems programming".to_string()),
1447 );
1448
1449 assert!(facet_matches(Some(&fm), "category", "programming"));
1450 assert!(facet_matches(Some(&fm), "category", "systems"));
1451 }
1452
1453 #[test]
1454 fn test_facet_matches_case_insensitive() {
1455 let mut fm = crate::markdown::SimpleMetadata::new();
1456 fm.insert(
1457 "category".to_string(),
1458 serde_json::Value::String("Systems Programming".to_string()),
1459 );
1460
1461 assert!(facet_matches(Some(&fm), "category", "PROGRAMMING"));
1462 assert!(facet_matches(Some(&fm), "category", "systems"));
1463 }
1464
1465 #[test]
1466 fn test_facet_matches_missing_field() {
1467 let mut fm = crate::markdown::SimpleMetadata::new();
1468 fm.insert(
1469 "title".to_string(),
1470 serde_json::Value::String("test".to_string()),
1471 );
1472
1473 assert!(!facet_matches(Some(&fm), "category", "anything"));
1474 }
1475
1476 #[test]
1477 fn test_facet_matches_no_frontmatter() {
1478 assert!(!facet_matches(None, "category", "anything"));
1479 }
1480
1481 #[test]
1482 fn test_facet_matches_no_match() {
1483 let mut fm = crate::markdown::SimpleMetadata::new();
1484 fm.insert(
1485 "category".to_string(),
1486 serde_json::Value::String("web development".to_string()),
1487 );
1488
1489 assert!(!facet_matches(Some(&fm), "category", "systems"));
1490 }
1491
1492 #[test]
1493 fn test_facet_matches_array_value() {
1494 let mut fm = crate::markdown::SimpleMetadata::new();
1495 fm.insert("tags".to_string(), serde_json::json!(["rust", "python"]));
1496
1497 assert!(facet_matches(Some(&fm), "tags", "rust"));
1498 assert!(facet_matches(Some(&fm), "tags", "python"));
1499 assert!(!facet_matches(Some(&fm), "tags", "go"));
1500 }
1501
1502 #[test]
1505 fn test_field_weight_priorities() {
1506 assert_eq!(field_weight("title"), 3);
1507 assert_eq!(field_weight("tags"), 2);
1508 assert_eq!(field_weight("keywords"), 2);
1509 assert_eq!(field_weight("categories"), 2);
1510 assert_eq!(field_weight("category"), 2);
1511 assert_eq!(field_weight("aliases"), 2);
1512 assert_eq!(field_weight("alias"), 2);
1513 assert_eq!(field_weight("description"), 1);
1514 assert_eq!(field_weight("summary"), 1);
1515 assert_eq!(field_weight("custom_field"), 1);
1516 }
1517
1518 #[test]
1521 fn test_folder_scope_deserialization() {
1522 assert_eq!(
1523 serde_json::from_str::<FolderScope>(r#""current""#).unwrap(),
1524 FolderScope::Current
1525 );
1526 assert_eq!(
1527 serde_json::from_str::<FolderScope>(r#""everywhere""#).unwrap(),
1528 FolderScope::Everywhere
1529 );
1530 }
1531
1532 #[test]
1533 fn test_search_query_with_folder_scope() {
1534 let json = r#"{"q": "test", "folder": "/docs/", "folder_scope": "current"}"#;
1535 let query: SearchQuery = serde_json::from_str(json).unwrap();
1536
1537 assert_eq!(query.folder, Some("/docs/".to_string()));
1538 assert_eq!(query.folder_scope, FolderScope::Current);
1539 }
1540
1541 #[test]
1542 fn test_search_query_folder_scope_default() {
1543 let json = r#"{"q": "test"}"#;
1544 let query: SearchQuery = serde_json::from_str(json).unwrap();
1545
1546 assert_eq!(query.folder_scope, FolderScope::Everywhere);
1547 }
1548
1549 #[test]
1552 fn test_build_snippet_short_line_not_truncated() {
1553 let snippet = build_snippet(9, "A distinctive brownfox appears here.");
1554 assert_eq!(snippet, "Line 9: A distinctive brownfox appears here.");
1555 assert!(!snippet.ends_with("..."));
1556 }
1557
1558 #[test]
1559 fn test_build_snippet_ascii_line_truncated_at_limit() {
1560 let line = "a".repeat(MAX_SNIPPET_LENGTH + 50);
1561 let snippet = build_snippet(1, &line);
1562 assert_eq!(
1563 snippet,
1564 format!("Line 1: {}...", "a".repeat(MAX_SNIPPET_LENGTH))
1565 );
1566 }
1567
1568 #[test]
1569 fn test_build_snippet_two_byte_char_straddling_cut() {
1570 let line = format!("x{}", "é".repeat(150));
1574 assert_eq!(line.len(), 301);
1575 assert!(!line.is_char_boundary(MAX_SNIPPET_LENGTH));
1576
1577 let expected_body = format!("x{}", "é".repeat(99));
1578 assert_eq!(expected_body.len(), MAX_SNIPPET_LENGTH - 1);
1579 assert_eq!(
1580 build_snippet(7, &line),
1581 format!("Line 7: {}...", expected_body)
1582 );
1583 }
1584
1585 #[test]
1586 fn test_build_snippet_four_byte_char_straddling_cut() {
1587 let line = format!("ab{}", "\u{1F98A}".repeat(100));
1590 assert_eq!(line.len(), 402);
1591 assert!(!line.is_char_boundary(MAX_SNIPPET_LENGTH));
1592
1593 let expected_body = format!("ab{}", "\u{1F98A}".repeat(49));
1594 assert_eq!(expected_body.len(), MAX_SNIPPET_LENGTH - 2);
1595 assert_eq!(
1596 build_snippet(3, &line),
1597 format!("Line 3: {}...", expected_body)
1598 );
1599 }
1600
1601 fn engine_over(files: &[(&str, &str)]) -> (SearchEngine, tempfile::TempDir) {
1606 let dir = tempfile::tempdir().expect("create temp dir");
1607 for (rel, contents) in files {
1608 let path = dir.path().join(rel);
1609 if let Some(parent) = path.parent() {
1610 std::fs::create_dir_all(parent).expect("create parent dir");
1611 }
1612 std::fs::write(&path, contents).expect("write fixture file");
1613 }
1614
1615 let repo = Repo::init(
1616 dir.path().to_path_buf(),
1617 "static",
1618 &["md".to_string()],
1619 &[],
1620 &[],
1621 "index.md",
1622 &[],
1623 &[],
1624 );
1625 repo.scan_all().expect("scan repo");
1626
1627 let engine = SearchEngine::new(Arc::new(repo));
1628 (engine, dir)
1629 }
1630
1631 fn engine_over_mem(vault: crate::vault::MemVault) -> SearchEngine {
1634 let repo = Repo::init_with_vault(
1635 Arc::new(vault),
1636 "static",
1637 &["md".to_string()],
1638 &[],
1639 &[],
1640 "index.md",
1641 &[],
1642 &[],
1643 );
1644 repo.scan_all().expect("scan repo");
1645 SearchEngine::new(Arc::new(repo))
1646 }
1647
1648 #[test]
1650 fn test_search_content_through_a_vault_without_local_paths() {
1651 let vault = crate::vault::MemVault::new()
1652 .with_file(
1653 "docs/notes.md",
1654 "---\ntitle: Ordinary\n---\n\n# Heading\n\nA distinctive brownfox appears here.\n",
1655 )
1656 .with_file("other.md", "# Unrelated\n\nNothing to see.\n");
1657 let engine = engine_over_mem(vault);
1658
1659 let response = engine.search(&content_query("brownfox")).expect("search");
1660
1661 assert_eq!(response.results.len(), 1, "{:?}", response.results);
1662 let hit = &response.results[0];
1663 assert_eq!(hit.url_path, "/docs/notes/");
1664 assert!(hit.is_content_match);
1665 assert_eq!(hit.title.as_deref(), Some("Ordinary"));
1666 let snippet = hit.snippet.as_deref().expect("snippet");
1667 assert!(snippet.starts_with("Line 7: "), "{snippet:?}");
1668 }
1669
1670 #[test]
1674 fn test_search_content_skips_files_that_are_not_downloaded() {
1675 let vault = crate::vault::MemVault::new()
1676 .with_file("local.md", "# Local\n\nzebrafish here\n")
1677 .with_file("cloud.md", "# Cloud\n\nzebrafish there\n");
1678 vault.set_availability("cloud.md", crate::vault::Availability::NotDownloaded);
1679 let engine = engine_over_mem(vault);
1680
1681 let response = engine.search(&content_query("zebrafish")).expect("search");
1682
1683 let urls: Vec<&str> = response
1684 .results
1685 .iter()
1686 .map(|r| r.url_path.as_str())
1687 .collect();
1688 assert_eq!(urls, vec!["/local/"]);
1689 assert_eq!(response.not_downloaded, 1, "the skip is reported");
1690 let json = serde_json::to_value(&response).unwrap();
1691 assert_eq!(json["not_downloaded"], 1);
1692 }
1693
1694 fn content_query(q: &str) -> SearchQuery {
1695 SearchQuery {
1696 q: q.to_string(),
1697 limit: DEFAULT_RESULT_LIMIT,
1698 scope: SearchScope::Content,
1699 filetype: None,
1700 folder: None,
1701 folder_scope: FolderScope::Everywhere,
1702 }
1703 }
1704
1705 #[test]
1706 fn test_search_content_finds_body_only_term() {
1707 let body = concat!(
1713 "---\n",
1714 "title: Ordinary Title\n",
1715 "---\n",
1716 "\n",
1717 "# Heading\n",
1718 "\n",
1719 "The quick tan animal jumped over the lazy dog.\n",
1720 "\n",
1721 "A distinctive brownfox appears here.\n",
1722 );
1723 let (engine, _dir) = engine_over(&[
1724 ("notes.md", body),
1725 (
1726 "other.md",
1727 "---\ntitle: Unrelated\n---\n\nNothing to see.\n",
1728 ),
1729 ]);
1730
1731 let response = engine.search(&content_query("brownfox")).expect("search");
1732
1733 assert_eq!(
1734 response.results.len(),
1735 1,
1736 "expected exactly one content hit, got {:?}",
1737 response.results
1738 );
1739 let hit = &response.results[0];
1740 assert_eq!(hit.url_path, "/notes/");
1741 assert!(hit.is_content_match, "hit must be flagged as content match");
1742 assert_eq!(hit.filetype, "markdown");
1743 assert_eq!(hit.score, 1, "one matching line => score 1");
1744
1745 let snippet = hit.snippet.as_deref().expect("content match has a snippet");
1746 assert!(!snippet.is_empty());
1747 assert!(
1748 snippet.starts_with("Line 9: "),
1749 "brownfox is on line 9; snippet was {snippet:?}"
1750 );
1751 assert!(snippet.contains("brownfox"), "snippet was {snippet:?}");
1752 }
1753
1754 #[test]
1755 fn test_search_content_scans_every_candidate_file() {
1756 let (engine, _dir) = engine_over(&[
1759 ("a.md", "intro line\nbrownfox here\n"),
1760 ("nested/b.md", "other line\nanother line\nbrownfox there\n"),
1761 ("c.md", "no match in this file\n"),
1762 ]);
1763
1764 let response = engine.search(&content_query("brownfox")).expect("search");
1765
1766 let mut paths: Vec<&str> = response.results.iter().map(|r| &*r.url_path).collect();
1767 paths.sort_unstable();
1768 assert_eq!(paths, vec!["/a/", "/nested/b/"]);
1769 assert!(response.results.iter().all(|r| r.is_content_match));
1770
1771 let snippets: std::collections::HashMap<&str, &str> = response
1772 .results
1773 .iter()
1774 .map(|r| (&*r.url_path, r.snippet.as_deref().unwrap_or("")))
1775 .collect();
1776 assert_eq!(snippets["/a/"], "Line 2: brownfox here");
1777 assert_eq!(snippets["/nested/b/"], "Line 3: brownfox there");
1778 }
1779
1780 #[test]
1781 fn test_search_content_scores_by_match_count() {
1782 let (engine, _dir) =
1783 engine_over(&[("many.md", "brownfox\nfiller\nbrownfox\nfiller\nbrownfox\n")]);
1784
1785 let response = engine.search(&content_query("brownfox")).expect("search");
1786
1787 assert_eq!(response.results.len(), 1);
1788 assert_eq!(response.results[0].score, 3, "three matching lines");
1789 assert_eq!(
1790 response.results[0].snippet.as_deref(),
1791 Some("Line 1: brownfox")
1792 );
1793 }
1794
1795 #[test]
1796 fn test_search_metadata_matches_without_cloning_candidates() {
1797 let (engine, _dir) = engine_over(&[
1800 (
1801 "guide.md",
1802 "---\ntitle: Widget Guide\ntags:\n - rust\n---\n\nbrownfox body only\n",
1803 ),
1804 ("misc.md", "---\ntitle: Something Else\n---\n\nfiller\n"),
1805 ]);
1806
1807 let query = SearchQuery {
1808 q: "Widget".to_string(),
1809 limit: DEFAULT_RESULT_LIMIT,
1810 scope: SearchScope::Metadata,
1811 filetype: None,
1812 folder: None,
1813 folder_scope: FolderScope::Everywhere,
1814 };
1815
1816 let response = engine.search(&query).expect("search");
1817 let hit = response
1818 .results
1819 .iter()
1820 .find(|r| r.url_path == "/guide/")
1821 .expect("guide.md matches on its frontmatter title");
1822 assert_eq!(hit.title.as_deref(), Some("Widget Guide"));
1823 assert_eq!(hit.tags.as_deref(), Some("rust"));
1824 assert!(!hit.is_content_match);
1825 assert!(hit.score > 0);
1826 }
1827
1828 #[test]
1831 fn test_search_response_scan_in_progress_serialization() {
1832 let response = SearchResponse {
1834 query: "test".to_string(),
1835 total_matches: 0,
1836 results: vec![],
1837 duration_ms: 0,
1838 scan_in_progress: false,
1839 not_downloaded: 0,
1840 };
1841 let json = serde_json::to_string(&response).unwrap();
1842 assert!(!json.contains("scan_in_progress"));
1843 assert!(!json.contains("not_downloaded"), "omitted when zero");
1844
1845 let response2 = SearchResponse {
1847 scan_in_progress: true,
1848 ..response
1849 };
1850 let json2 = serde_json::to_string(&response2).unwrap();
1851 assert!(json2.contains("\"scan_in_progress\":true"));
1852 }
1853}