Skip to main content

mbr_core/
search.rs

1//! Search functionality for the mbr markdown browser.
2//!
3//! Provides two search modes:
4//! - **Metadata search**: Fast fuzzy matching of titles, paths, and frontmatter
5//!   using nucleo-matcher. Data is already in memory from repo scanning.
6//! - **Content search**: Full-text search through markdown file contents
7//!   using grep-searcher with SIMD acceleration.
8//!
9//! Supports faceted search with `key:value` syntax for filtering on specific
10//! frontmatter fields (e.g., `category:rust` or `tags:async`).
11//!
12//! Both modes use sequential iteration to avoid rayon thread pool contention
13//! when multiple searches run concurrently (e.g., from per-keystroke queries).
14
15use 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
29/// Maximum number of search results to return by default.
30pub const DEFAULT_RESULT_LIMIT: usize = 50;
31
32/// Maximum snippet length in characters.
33const MAX_SNIPPET_LENGTH: usize = 200;
34
35/// Context lines to show around content matches.
36const CONTENT_CONTEXT_LINES: usize = 1;
37
38/// Base score for facet (key:value) matches against metadata.
39const FACET_MATCH_BASE_SCORE: u32 = 100;
40
41/// Maximum score for content (full-text) matches.
42const MAX_CONTENT_MATCH_SCORE: u32 = 100;
43
44/// Maximum bytes of extracted text to sample for fuzzy matching.
45const MAX_TEXT_SAMPLE_BYTES: usize = 5000;
46
47/// Parsed query with separated terms and facets.
48///
49/// A query like `rust category:programming author:alice` is parsed into:
50/// - terms: `["rust"]`
51/// - facets: `[("category", "programming"), ("author", "alice")]`
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ParsedQuery {
54    /// Plain search terms (AND'd together for matching).
55    pub terms: Vec<String>,
56    /// Facet filters as (field_name, value) pairs.
57    pub facets: Vec<(String, String)>,
58}
59
60impl ParsedQuery {
61    /// Returns true if the query is empty (no terms and no facets).
62    pub fn is_empty(&self) -> bool {
63        self.terms.is_empty() && self.facets.is_empty()
64    }
65}
66
67/// Parse a query string into terms and facets.
68///
69/// Facets use `key:value` syntax. URLs (containing `://`) are preserved as terms.
70///
71/// # Examples
72///
73/// ```
74/// use mbr_core::search::parse_query;
75///
76/// let parsed = parse_query("rust async");
77/// assert_eq!(parsed.terms, vec!["rust", "async"]);
78/// assert!(parsed.facets.is_empty());
79///
80/// let parsed = parse_query("category:rust guide");
81/// assert_eq!(parsed.terms, vec!["guide"]);
82/// assert_eq!(parsed.facets, vec![("category".to_string(), "rust".to_string())]);
83/// ```
84pub 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        // Check if this looks like a facet (contains : but not ://)
90        if let Some(colon_pos) = token.find(':') {
91            // Skip if it's a URL (contains ://)
92            if token.contains("://") {
93                terms.push(token.to_string());
94                continue;
95            }
96
97            // Skip if colon is at start or end (not a valid facet)
98            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..]; // Skip the colon
105            // `key:"two words"` — drop the quotes `query_tokens` kept. A value
106            // whose closing quote has not been typed yet loses just the opening
107            // one, so the search narrows while the user is still typing.
108            let value = value
109                .strip_prefix('"')
110                .map(|v| v.strip_suffix('"').unwrap_or(v))
111                .unwrap_or(value);
112
113            // Only add if both key and value are non-empty
114            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
127/// Split a query on whitespace, except inside a quoted facet value.
128///
129/// `type:"Meeting Notes"` is one token, so a facet can name a frontmatter value
130/// that contains spaces — the search panel's note-type selector writes exactly
131/// this form. Quoting is recognised only directly after `key:` (a non-empty key
132/// with no `:` or `"` of its own) and only when the closing quote exists;
133/// anything else splits on whitespace as it always did, so a stray `"` in a
134/// plain term cannot swallow the rest of the query.
135fn 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
158/// Check if a frontmatter field value contains the facet value (case-insensitive).
159/// Handles both string values and array values (for array tags).
160fn 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
176/// Build a `"Line {n}: {text}"` snippet for a content match, truncating the
177/// matched line to [`MAX_SNIPPET_LENGTH`] bytes when it is longer.
178///
179/// Truncation is clamped with `floor_char_boundary`, so a multi-byte character
180/// straddling the cut is dropped whole rather than sliced in half (a naive
181/// `&line[..MAX_SNIPPET_LENGTH]` would panic on such a line).
182fn 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
194/// Get the scoring weight for a frontmatter field.
195fn field_weight(field: &str) -> u32 {
196    match field {
197        "title" => 3,
198        // An exact maiden-name (alias) hit is a strong signal, like a tag.
199        "tags" | "keywords" | "categories" | "category" | "aliases" | "alias" => 2,
200        "description" | "summary" => 1,
201        _ => 1, // All other fields get base weight
202    }
203}
204
205/// Folder scope determines whether to search everywhere or just current folder.
206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
207#[serde(rename_all = "lowercase")]
208pub enum FolderScope {
209    /// Search only within the specified folder and subfolders.
210    Current,
211    /// Search the entire repository.
212    #[default]
213    Everywhere,
214}
215
216/// Search query with optional facets for filtering.
217#[derive(Debug, Clone, Deserialize)]
218pub struct SearchQuery {
219    /// The search query string (supports `key:value` facet syntax).
220    pub q: String,
221
222    /// Maximum number of results to return.
223    #[serde(default = "default_limit")]
224    pub limit: usize,
225
226    /// Search scope: "metadata", "content", or "all" (default: "all").
227    #[serde(default = "default_scope")]
228    pub scope: SearchScope,
229
230    /// File type filter: "markdown" or "all" (includes PDFs and text files).
231    #[serde(default)]
232    pub filetype: Option<String>,
233
234    /// Folder path prefix for filtering (e.g., "/docs/").
235    #[serde(default)]
236    pub folder: Option<String>,
237
238    /// Folder scope: "current" (within folder) or "everywhere" (whole repo).
239    #[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/// Search scope determines what fields are searched.
252#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
253#[serde(rename_all = "lowercase")]
254pub enum SearchScope {
255    /// Search only metadata (title, path, tags, frontmatter).
256    Metadata,
257    /// Search only file content (markdown body).
258    Content,
259    /// Search both metadata and content.
260    #[default]
261    All,
262}
263
264/// A single search result with matched file information.
265#[derive(Debug, Clone, Serialize)]
266pub struct SearchResult {
267    /// URL path to the file.
268    pub url_path: String,
269
270    /// File title (from frontmatter or filename).
271    pub title: Option<String>,
272
273    /// File description (from frontmatter).
274    pub description: Option<String>,
275
276    /// Tags from frontmatter.
277    pub tags: Option<String>,
278
279    /// Match score (higher is better).
280    pub score: u32,
281
282    /// Snippet of matched content with context.
283    pub snippet: Option<String>,
284
285    /// Whether this is a content match (vs metadata match).
286    pub is_content_match: bool,
287
288    /// File type category.
289    pub filetype: String,
290}
291
292/// Search response containing results and metadata.
293#[derive(Debug, Clone, Serialize)]
294pub struct SearchResponse {
295    /// The original query string.
296    pub query: String,
297
298    /// Total number of matches found (before limit applied).
299    pub total_matches: usize,
300
301    /// Search results, ordered by score descending.
302    pub results: Vec<SearchResult>,
303
304    /// Time taken to search in milliseconds.
305    pub duration_ms: u64,
306
307    /// True when the background scan is still in progress (results may be incomplete).
308    #[serde(skip_serializing_if = "std::ops::Not::not")]
309    pub scan_in_progress: bool,
310
311    /// Notes content search had to skip because their bytes are not on this
312    /// device (a cloud file provider's dataless files). Omitted when zero,
313    /// which is always on the desktop.
314    #[serde(skip_serializing_if = "is_zero")]
315    pub not_downloaded: usize,
316}
317
318fn is_zero(count: &usize) -> bool {
319    *count == 0
320}
321
322/// What content search found, plus the files it could not read because they
323/// are not downloaded.
324struct ContentHits {
325    results: Vec<SearchResult>,
326    not_downloaded: usize,
327}
328
329/// Search engine that combines metadata and content search.
330///
331/// Content search reads files through the repository's vault
332/// ([`Repo::vault`]); see [`SearchEngine::search_file_content`].
333pub struct SearchEngine {
334    repo: Arc<Repo>,
335}
336
337impl SearchEngine {
338    /// Create a new search engine with access to the repository.
339    pub fn new(repo: Arc<Repo>) -> Self {
340        Self { repo }
341    }
342
343    /// Execute a search query and return results.
344    pub fn search(&self, query: &SearchQuery) -> Result<SearchResponse, SearchError> {
345        let start = std::time::Instant::now();
346
347        // Parse the query to extract facets
348        let parsed = parse_query(&query.q);
349
350        // Early return for empty queries
351        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        // Metadata search (always fast, data in memory)
366        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        // Content search (requires file I/O) - only if we have search terms
372        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        // Search other files (PDFs, text files) when filetype is "all"
379        if self.should_search_other_files(&query.filetype) {
380            all_results.extend(self.search_other_files_metadata(query, &parsed)?);
381        }
382
383        // Deduplicate results (same file might match both metadata and content)
384        all_results = Self::deduplicate_results(all_results);
385
386        // Sort by score descending
387        all_results.sort_by_key(|r| std::cmp::Reverse(r.score));
388
389        let total_matches = all_results.len();
390
391        // Apply limit
392        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    /// Search metadata (titles, paths, tags, frontmatter) using nucleo-matcher.
407    fn search_metadata(
408        &self,
409        query: &SearchQuery,
410        parsed: &ParsedQuery,
411    ) -> Result<Vec<SearchResult>, SearchError> {
412        // Build pattern from terms only (facets are handled separately)
413        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        // Intentionally sequential: par_iter causes 30s stalls from rayon thread pool contention
424        // when multiple per-keystroke searches run concurrently.
425        let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
426
427        // Match straight off the pin guard: metadata matching is pure in-memory
428        // CPU work, so borrowing `&MarkdownInfo` avoids deep-cloning the whole
429        // candidate set (PathBuf + Strings + relationships + frontmatter map)
430        // just to iterate it once. Unlike `search_content` there is no file I/O
431        // here, so the guard is not held across a blocking call.
432        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            // Apply facet filters - all facets must match
439            .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    /// Search tag pages (virtual pages generated from the tag index).
452    ///
453    /// For each configured tag source, fuzzy-matches tag display values against
454    /// the search pattern. Matching tags produce results pointing to their tag page URL.
455    fn search_tag_pages(&self, query: &SearchQuery, parsed: &ParsedQuery) -> Vec<SearchResult> {
456        // Tag pages are global — skip when scoped to a folder
457        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    /// Score a single tag page against the search query.
512    ///
513    /// Returns Some(score) if the tag matches, None otherwise.
514    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        // For facet-only queries, check if any facet matches this tag source+value
524        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        // Match against tag display value (high priority — 2x boost)
539        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        // Match against "Label: Value" (e.g., "Performer: Joshua Jay")
544        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    /// Match a single file's metadata against the pattern.
553    ///
554    /// If pattern is None (facet-only query), returns a base score for matching files.
555    fn match_metadata(
556        &self,
557        pattern: Option<&Pattern>,
558        info: &MarkdownInfo,
559        matcher: &mut Matcher,
560    ) -> Option<SearchResult> {
561        // Helper to extract string from frontmatter value
562        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        // If no pattern (facet-only query), return base score for files that passed facet filter
578        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        // Match against URL path (high priority - 2x boost)
603        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        // Match against filename (high priority - 2x boost)
608        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        // Match against all frontmatter fields with dynamic weights
615        if let Some(ref fm) = info.frontmatter {
616            for (key, value) in fm.iter() {
617                // Match against string values or each element of arrays
618                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    /// Perform fuzzy matching and return score.
666    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    /// Search file contents using grep-searcher.
673    fn search_content(
674        &self,
675        query: &SearchQuery,
676        parsed: &ParsedQuery,
677    ) -> Result<ContentHits, SearchError> {
678        // Build regex pattern from terms (escape special characters for literal search)
679        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(&regex_pattern)
684            .map_err(|e| SearchError::PatternInvalid {
685                pattern: search_terms,
686                reason: e.to_string(),
687            })?;
688
689        // Collect files to search, applying facet filters
690        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                // Apply facet filters - all facets must match
700                .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        // One `Searcher` for the whole query, reused across files. Building one
709        // allocates ~72 KB of zero-filled decode/line buffers, so constructing
710        // it per file dominated the scan cost on large repos. `Searcher` is
711        // explicitly designed for reuse across `search_path` calls.
712        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        // Sequential content search.
720        // Same rationale as metadata: concurrent par_iter calls from rapid
721        // keystroke searches cause 30s rayon thread pool contention. Sequential
722        // grep over 276 files takes ~13ms.
723        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    /// Search a single file's content.
740    ///
741    /// `searcher` is owned by the caller and reused across every file in a
742    /// query — see [`SearchEngine::search_content`].
743    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        // `raw_path` is repo-relative: it names the file inside the
751        // repository's vault. Reading it as a native path would resolve against
752        // the process working directory, which is not the repo root.
753        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                // Keep first few matches for snippet
764                matches.push((line_num, line.trim().to_string()));
765            }
766            Ok(true)
767        });
768
769        // Search the file. Where it has a real path, grep reads it itself
770        // (its own buffering or mmap strategy, exactly as before the vault);
771        // otherwise its bytes come through the vault and grep searches the
772        // slice. A file that is not downloaded yet is skipped, like one that
773        // has vanished since the scan, and counted: a dataless file read with
774        // the fail-fast I/O policy fails with `EDEADLK` (`ErrorKind::Deadlock`,
775        // see `VaultError::from_io`), through either route.
776        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            // Build snippet from first match
805            let snippet = matches
806                .first()
807                .map(|(line_num, line)| build_snippet(*line_num, line));
808
809            // Score based on match count (more matches = higher score)
810            // Content matches are weighted lower than metadata matches
811            let score = match_count.min(MAX_CONTENT_MATCH_SCORE);
812
813            // Helper to extract string from frontmatter value
814            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    /// Check if a file matches the folder filter based on folder scope.
856    fn matches_folder_filter(
857        &self,
858        info: &MarkdownInfo,
859        folder: &Option<String>,
860        folder_scope: &FolderScope,
861    ) -> bool {
862        // If scope is Everywhere, always match
863        if *folder_scope == FolderScope::Everywhere {
864            return true;
865        }
866
867        // For Current scope, check folder prefix
868        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, // No folder specified = match all
878        }
879    }
880
881    /// Check if a file matches the filetype filter.
882    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    /// Check if we should search other files (PDFs, text files) based on filetype filter.
902    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    /// Search other files (PDFs, text files) by path and extracted text.
910    fn search_other_files_metadata(
911        &self,
912        query: &SearchQuery,
913        parsed: &ParsedQuery,
914    ) -> Result<Vec<SearchResult>, SearchError> {
915        // Build pattern from terms (facets don't apply to other files - they have no frontmatter)
916        let pattern = if parsed.terms.is_empty() {
917            // No terms and other files have no frontmatter, so facet-only queries return nothing
918            return Ok(Vec::new());
919        } else {
920            Pattern::parse(
921                &parsed.terms.join(" "),
922                CaseMatching::Ignore,
923                Normalization::Smart,
924            )
925        };
926
927        // Collect searchable files (PDFs and text files with extracted text)
928        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        // Sequential fuzzy matching (same rayon contention rationale as search_metadata)
941        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    /// Match a single other file against the pattern.
951    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        // Match against URL path
960        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        // Match against filename
965        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        // Match against extracted text (lower weight since it's body content)
972        if let Some(ref text) = info.extracted_text {
973            // Sample the text for matching (first ~5000 bytes for performance)
974            // Use floor_char_boundary to avoid slicing in the middle of a UTF-8 character
975            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            // Build snippet from extracted text
987            let snippet = info.extracted_text.as_ref().map(|text| {
988                if text.len() > MAX_SNIPPET_LENGTH {
989                    // Use floor_char_boundary to avoid slicing in the middle of a UTF-8 character
990                    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    /// Check if an other file matches the folder filter.
1017    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    /// Deduplicate results, preferring higher-scoring matches.
1041    fn deduplicate_results(mut results: Vec<SearchResult>) -> Vec<SearchResult> {
1042        // Sort by URL path, then by score descending
1043        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        // Deduplicate keeping highest score, merging content match info
1050        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                    // Keep the higher score
1055                    if result.score > existing.score {
1056                        existing.score = result.score;
1057                    }
1058                    // If either is a content match, keep the snippet
1059                    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
1071/// Search other files (PDFs, images, etc.) by path only.
1072/// This is a lighter-weight search that doesn't read file contents.
1073pub 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            // Folder filter
1088            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            // Filetype filter
1100            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    // Sequential fuzzy matching (same rayon contention rationale as search_metadata)
1131    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    // Sort by score and limit
1169    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); // Higher score kept
1248        assert!(result.snippet.is_some()); // Content snippet kept
1249    }
1250
1251    // ==================== Query Parser Tests ====================
1252
1253    #[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        // Mid-typing: the closing quote is not there yet.
1336        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        /// Arbitrary input never panics, and every token's text survives into
1371        /// either a term or a facet (nothing is silently dropped).
1372        #[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        /// A quoted facet value round-trips whole, whatever spaces it holds.
1380        #[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    // ==================== Facet Matching Tests ====================
1429
1430    #[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    // ==================== Field Weight Tests ====================
1503
1504    #[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    // ==================== Folder Scope Tests ====================
1519
1520    #[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    // ==================== Snippet Truncation Tests ====================
1550
1551    #[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        // One ASCII byte then 2-byte chars puts every char boundary on an odd
1571        // byte offset, so MAX_SNIPPET_LENGTH (200, even) lands *inside* a
1572        // character. A naive `&line[..200]` would panic here.
1573        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        // Two ASCII bytes then 4-byte chars: boundaries at 2, 6, 10, ...
1588        // so byte 200 is mid-character (2 + 4k == 200 has no integer solution).
1589        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    // ==================== Content Search Tests ====================
1602
1603    /// Build a `SearchEngine` over a temp repo of `(relative path, contents)`
1604    /// pairs. The returned `TempDir` must stay alive for the whole test.
1605    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    /// [`engine_over`] for a repository in a `MemVault`: no `local_path`, so
1632    /// every content search goes through `search_slice` on vault bytes.
1633    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    /// The same body-only hit as on disk, through a vault with no filesystem.
1649    #[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    /// A note that is listed but not downloaded is skipped by content search
1671    /// (its metadata, from the listing, is still searchable) — never a hang or
1672    /// an error.
1673    #[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        // `brownfox` appears ONLY in the body of notes.md — not in its file
1708        // name, not in its frontmatter — so metadata search cannot satisfy
1709        // this assertion. If `search_file_content` silently returns None (for
1710        // example because `root_dir.join(raw_path)` no longer exists) this
1711        // fails instead of passing vacuously.
1712        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        // Guards the single hoisted `Searcher` reused across files: if reuse
1757        // left stale state behind, only the first file would report a hit.
1758        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        // Metadata search now iterates borrowed `&MarkdownInfo` off the pin
1798        // guard; assert it still resolves title/tags out of frontmatter.
1799        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    // ==================== SearchResponse Serialization Tests ====================
1829
1830    #[test]
1831    fn test_search_response_scan_in_progress_serialization() {
1832        // Verify scan_in_progress is skipped when false (default)
1833        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        // Verify scan_in_progress is present when true
1846        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}