1use domain::error::Result;
2use domain::model::*;
3use domain::ports::SearchIndex;
4
5use crate::mapping::*;
6use crate::SqliteStore;
7
8impl SearchIndex for SqliteStore {
9 fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
10 if query.is_empty() {
11 return Ok(vec![]);
12 }
13
14 const MAX_QUERY_LEN: usize = 500;
17 let query = if query.len() > MAX_QUERY_LEN {
18 let mut end = MAX_QUERY_LEN;
19 while end > 0 && !query.is_char_boundary(end) {
20 end -= 1;
21 }
22 &query[..end]
23 } else {
24 query
25 };
26
27 let conn = self.conn()?;
28
29 let sanitized = query.replace('"', "\"\"");
34 let fts_query = format!("\"{sanitized}\"*");
35
36 let mut stmt = conn
37 .prepare_cached(
38 "SELECT s.qualified_name, s.name, s.kind, s.file_path, rank
39 FROM symbols_fts
40 JOIN symbols s ON symbols_fts.rowid = s.rowid
41 WHERE symbols_fts MATCH ?1
42 ORDER BY rank
43 LIMIT ?2",
44 )
45 .map_err(map_rusqlite_error)?;
46
47 let rows = stmt
48 .query_map(rusqlite::params![&fts_query, limit as i64], |row| {
49 Ok((
50 row.get::<_, String>(0)?,
51 row.get::<_, String>(1)?,
52 row.get::<_, String>(2)?,
53 row.get::<_, String>(3)?,
54 row.get::<_, f64>(4)?,
55 ))
56 })
57 .map_err(map_rusqlite_error)?;
58
59 let mut results = Vec::new();
60 for row in rows {
61 let (qn, name, kind, file, score) = row.map_err(map_rusqlite_error)?;
62 results.push(SearchResult {
63 qualified_name: qn,
64 name,
65 kind: symbol_kind_from_str(&kind)?,
66 file_path: file.into(),
67 score: -score, score_source: None,
69 });
70 }
71 Ok(results)
72 }
73
74 fn index_symbol(&self, _symbol: &SymbolNode) -> Result<()> {
75 Ok(())
77 }
78
79 fn rebuild(&self) -> Result<()> {
80 Ok(())
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88 use domain::ports::{GraphStore, SearchIndex};
89
90 fn test_store() -> SqliteStore {
91 SqliteStore::open_in_memory().unwrap()
92 }
93
94 fn file_and_symbol(name: &str, qn: &str) -> (FileNode, SymbolNode) {
95 let file_path = qn.split("::").next().unwrap();
96 (
97 FileNode {
98 path: file_path.into(),
99 language: Language::Rust,
100 hash: "h".into(),
101 },
102 SymbolNode {
103 name: name.into(),
104 qualified_name: qn.into(),
105 kind: SymbolKind::Function,
106 location: Location {
107 file: file_path.into(),
108 line_start: 1,
109 line_end: 10,
110 col_start: 0,
111 col_end: 1,
112 },
113 visibility: Visibility::Public,
114 is_exported: true,
115 is_async: false,
116 is_test: false,
117 decorators: vec![],
118 signature: Some(format!("fn {name}()")),
119 },
120 )
121 }
122
123 #[test]
124 fn insert_symbol_makes_it_searchable() {
125 let store = test_store();
126 let (file, sym) = file_and_symbol("UserService", "src/user.rs::UserService");
127 store.upsert_file(&file).unwrap();
128 store.upsert_symbol(&sym).unwrap();
129 let results = store.search("UserService", 10).unwrap();
130 assert_eq!(results.len(), 1);
131 assert_eq!(results[0].name, "UserService");
132 }
133
134 #[test]
135 fn delete_symbol_removes_from_search() {
136 let store = test_store();
137 let (file, sym) = file_and_symbol("UserService", "src/user.rs::UserService");
138 store.upsert_file(&file).unwrap();
139 store.upsert_symbol(&sym).unwrap();
140 store.remove_file("src/user.rs".as_ref()).unwrap();
141 let results = store.search("UserService", 10).unwrap();
142 assert!(results.is_empty());
143 }
144
145 #[test]
146 fn update_symbol_updates_search() {
147 let store = test_store();
148 let file = FileNode {
149 path: "src/a.rs".into(),
150 language: Language::Rust,
151 hash: "h".into(),
152 };
153 store.upsert_file(&file).unwrap();
154
155 let mut sym = SymbolNode {
157 name: "AlphaName".into(),
158 qualified_name: "src/a.rs::mysym".into(),
159 kind: SymbolKind::Function,
160 location: Location {
161 file: "src/a.rs".into(),
162 line_start: 1,
163 line_end: 10,
164 col_start: 0,
165 col_end: 1,
166 },
167 visibility: Visibility::Public,
168 is_exported: true,
169 is_async: false,
170 is_test: false,
171 decorators: vec![],
172 signature: None,
173 };
174 store.upsert_symbol(&sym).unwrap();
175 assert!(!store.search("AlphaName", 10).unwrap().is_empty());
176
177 sym.name = "BetaName".into();
179 store.upsert_symbol(&sym).unwrap();
180 assert!(store.search("AlphaName", 10).unwrap().is_empty());
181 assert!(!store.search("BetaName", 10).unwrap().is_empty());
182 }
183
184 #[test]
185 fn search_ranks_exact_match_higher() {
186 let store = test_store();
187 let (f1, s1) = file_and_symbol("User", "src/a.rs::User");
188 let (f2, s2) = file_and_symbol("UserService", "src/b.rs::UserService");
189 store.upsert_file(&f1).unwrap();
190 store.upsert_symbol(&s1).unwrap();
191 store.upsert_file(&f2).unwrap();
192 store.upsert_symbol(&s2).unwrap();
193 let results = store.search("User", 10).unwrap();
194 assert!(results.len() >= 1);
195 assert_eq!(results[0].name, "User");
196 }
197
198 #[test]
199 fn search_empty_query_returns_empty() {
200 let store = test_store();
201 let results = store.search("", 10).unwrap();
202 assert!(results.is_empty());
203 }
204
205 #[test]
206 fn search_respects_limit() {
207 let store = test_store();
208 for i in 0..5 {
209 let name = format!("func_{i}");
210 let qn = format!("src/f{i}.rs::{name}");
211 let (f, s) = file_and_symbol(&name, &qn);
212 store.upsert_file(&f).unwrap();
213 store.upsert_symbol(&s).unwrap();
214 }
215 let results = store.search("func", 3).unwrap();
216 assert!(results.len() <= 3);
217 }
218
219 #[test]
220 fn index_symbol_is_noop() {
221 let store = test_store();
222 let (_, sym) = file_and_symbol("Test", "src/t.rs::Test");
223 store.index_symbol(&sym).unwrap();
224 }
225
226 #[test]
227 fn rebuild_is_noop() {
228 let store = test_store();
229 store.rebuild().unwrap();
230 }
231}