Skip to main content

lore_engine/engine/
search.rs

1//! Full-text and title search over the vault's FTS5 index.
2
3use super::error::LoreError;
4use rusqlite::{params, Connection};
5use serde::Serialize;
6
7/// A single search result with relevance ranking.
8#[derive(Debug, Serialize)]
9pub struct SearchResult {
10    pub slug: String,
11    pub title: String,
12    /// HTML snippet with `<mark>` tags around matched terms (FTS only).
13    pub snippet: String,
14    /// BM25 relevance score (lower = more relevant). 0.0 for title-only search.
15    pub rank: f64,
16}
17
18/// Full-text search across all pages. Returns results ranked by BM25 relevance.
19pub fn search_pages(
20    conn: &Connection,
21    query: &str,
22    limit: usize,
23) -> Result<Vec<SearchResult>, LoreError> {
24    let query = query.trim();
25    if query.is_empty() {
26        return Ok(Vec::new());
27    }
28
29    let fts_query = sanitize_fts_query(query);
30
31    let mut stmt = conn.prepare(
32        "SELECT slug, title,
33                snippet(pages_fts, 2, '<mark>', '</mark>', '...', 40) as snippet,
34                rank
35         FROM pages_fts
36         WHERE pages_fts MATCH ?1
37         ORDER BY rank
38         LIMIT ?2",
39    )?;
40
41    let rows = stmt.query_map(params![fts_query, i64::try_from(limit).unwrap_or(i64::MAX)], |row| {
42        Ok(SearchResult {
43            slug: row.get(0)?,
44            title: row.get(1)?,
45            snippet: row.get(2)?,
46            rank: row.get(3)?,
47        })
48    })?;
49
50    let mut results = Vec::new();
51    for row in rows {
52        results.push(row?);
53    }
54    Ok(results)
55}
56
57/// Title-only substring search for the quick switcher.
58///
59/// Uses SQL `LIKE %query%` — fine for personal wiki scale.
60pub fn search_titles(
61    conn: &Connection,
62    query: &str,
63    limit: usize,
64) -> Result<Vec<SearchResult>, LoreError> {
65    let query = query.trim();
66    if query.is_empty() {
67        return Ok(Vec::new());
68    }
69
70    let pattern = format!("%{query}%");
71    let mut stmt = conn.prepare(
72        "SELECT slug, title, '' as snippet, 0.0 as rank
73         FROM pages
74         WHERE title LIKE ?1 COLLATE NOCASE
75         ORDER BY title COLLATE NOCASE
76         LIMIT ?2",
77    )?;
78
79    let rows = stmt.query_map(params![pattern, i64::try_from(limit).unwrap_or(i64::MAX)], |row| {
80        Ok(SearchResult {
81            slug: row.get(0)?,
82            title: row.get(1)?,
83            snippet: row.get(2)?,
84            rank: row.get(3)?,
85        })
86    })?;
87
88    let mut results = Vec::new();
89    for row in rows {
90        results.push(row?);
91    }
92    Ok(results)
93}
94
95/// Sanitize a user query for FTS5.
96/// Adds prefix matching (*) to each term so "hel wor" matches "hello world".
97fn sanitize_fts_query(query: &str) -> String {
98    query
99        .split_whitespace()
100        .map(|term| {
101            let clean: String = term
102                .chars()
103                .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
104                .collect();
105            if clean.is_empty() {
106                String::new()
107            } else {
108                format!("{clean}*")
109            }
110        })
111        .filter(|s| !s.is_empty())
112        .collect::<Vec<_>>()
113        .join(" ")
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_sanitize_fts_query() {
122        assert_eq!(sanitize_fts_query("hello world"), "hello* world*");
123        assert_eq!(sanitize_fts_query("rust-lang"), "rust-lang*");
124        assert_eq!(sanitize_fts_query(""), "");
125        assert_eq!(sanitize_fts_query("   "), "");
126    }
127}