lore_engine/engine/
search.rs1use super::error::LoreError;
4use rusqlite::{params, Connection};
5use serde::Serialize;
6
7#[derive(Debug, Serialize)]
9pub struct SearchResult {
10 pub slug: String,
11 pub title: String,
12 pub snippet: String,
14 pub rank: f64,
16}
17
18pub 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
57pub 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
95fn 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}