1use std::path::Path;
19
20use anyhow::{Context, Result};
21use rusqlite::Connection;
22
23use crate::model::CodeNode;
24use crate::search::tokenize::extract_keywords;
25
26const CREATE_ENTITIES_V2: &str = "CREATE VIRTUAL TABLE IF NOT EXISTS entities USING fts5(
31 name,
32 kind,
33 signature,
34 source,
35 file_path,
36 node_json,
37 tokens
38);";
39
40pub struct SearchStore {
45 conn: Connection,
46}
47
48impl SearchStore {
49 pub fn open(path: impl AsRef<Path>) -> Result<(Self, bool)> {
56 let conn = Connection::open(path.as_ref())
57 .context("打开 SQLite 数据库失败")?;
58
59 conn.pragma_update(None, "journal_mode", "WAL")
61 .context("设置 WAL 模式失败")?;
62 conn.busy_timeout(std::time::Duration::from_secs(5))
64 .context("设置 busy_timeout 失败")?;
65
66 let version: i64 = conn
68 .query_row("PRAGMA user_version", [], |row| row.get(0))
69 .context("读取 user_version 失败")?;
70 let table_exists: bool = conn
71 .query_row(
72 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'entities')",
73 [],
74 |row| row.get(0),
75 )
76 .context("查询 entities 表存在性失败")?;
77
78 if !table_exists {
79 conn.execute_batch(CREATE_ENTITIES_V2)
81 .context("创建 FTS5 表失败")?;
82 conn.pragma_update(None, "user_version", 2)
83 .context("写入 user_version 失败")?;
84 return Ok((Self { conn }, false));
85 }
86
87 if version < 2 {
88 conn.execute_batch("DROP TABLE IF EXISTS entities;")
92 .context("删除旧 FTS5 表失败")?;
93 conn.execute_batch(CREATE_ENTITIES_V2)
94 .context("重建 FTS5 表失败")?;
95 conn.pragma_update(None, "user_version", 2)
96 .context("写入 user_version 失败")?;
97 return Ok((Self { conn }, true));
98 }
99
100 Ok((Self { conn }, false))
101 }
102
103 fn cjk_tokens(parts: &[&str]) -> String {
112 let mut out: Vec<String> = Vec::new();
113 for part in parts {
114 for k in extract_keywords(part) {
115 if k.chars().all(|c| matches!(c as u32, 0x4E00..=0x9FFF | 0x3400..=0x4DBF | 0xF900..=0xFAFF)) {
117 out.push(k);
118 }
119 }
120 }
121 out.join(" ")
122 }
123
124 pub fn insert_entities_batch(&self, items: &[(CodeNode, String)]) -> Result<()> {
126 let mut stmt = self.conn.prepare(
127 "INSERT INTO entities (name, kind, signature, source, file_path, node_json, tokens)
128 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
129 ).context("准备 FTS5 插入语句失败")?;
130
131 for (node, source) in items {
132 let node_json = serde_json::to_string(node)
133 .context("序列化 CodeNode 失败")?;
134 let tokens = Self::cjk_tokens(&[
135 &node.name,
136 node.signature.as_deref().unwrap_or(""),
137 source,
138 ]);
139 stmt.execute(rusqlite::params![
140 node.name,
141 node.kind.as_str(),
142 node.signature.as_deref().unwrap_or(""),
143 source,
144 crate::incremental::norm_sep(node.file_path.as_deref().unwrap_or("")).as_str(),
148 node_json,
149 tokens,
150 ]).context("插入 FTS5 条目失败")?;
151 }
152 Ok(())
153 }
154
155 fn build_match_terms(query: &str) -> Vec<String> {
162 extract_keywords(query)
163 }
164
165 pub fn search_fts(&self, query: &str, limit: usize) -> Result<Vec<(CodeNode, f64)>> {
167 let terms = Self::build_match_terms(query);
171 if terms.is_empty() {
172 return Ok(Vec::new());
173 }
174 let match_expr = format!("{{name signature source tokens}} : ({})", terms.join(" OR "));
177
178 let sql = format!(
180 "SELECT node_json, bm25(entities) as rank
181 FROM entities
182 WHERE entities MATCH ?1
183 ORDER BY rank
184 LIMIT {}",
185 limit
186 );
187 let mut stmt = self.conn.prepare(&sql)
188 .context("准备 FTS5 查询语句失败")?;
189
190 let rows = match stmt.query_map(rusqlite::params![match_expr], |row| {
194 let node_json: String = row.get(0)?;
195 let rank: f64 = row.get(1)?;
196 Ok((node_json, rank))
197 }) {
198 Ok(rows) => rows,
199 Err(e) => {
200 tracing::warn!("FTS5 查询语法错误,返回空结果: {} (query: {})", e, query);
201 return Ok(Vec::new());
202 }
203 };
204
205 let mut results = Vec::new();
206 for row in rows {
207 let (node_json, rank) = row.context("读取 FTS5 结果行失败")?;
208 if let Ok(node) = serde_json::from_str::<CodeNode>(&node_json) {
209 results.push((node, -rank));
211 }
212 }
213 Ok(results)
214 }
215
216 pub fn delete_entities_by_file(&self, file_path: &str) -> Result<usize> {
221 let count = self.conn.execute(
222 "DELETE FROM entities WHERE file_path = ?1",
223 rusqlite::params![crate::incremental::norm_sep(file_path)],
224 ).context("删除 FTS5 条目失败")?;
225 Ok(count)
226 }
227
228 pub fn entity_count(&self) -> Result<usize> {
230 let count: usize = self.conn.query_row(
231 "SELECT COUNT(*) FROM entities",
232 [],
233 |row| row.get(0),
234 ).context("查询 FTS5 文档数失败")?;
235 Ok(count)
236 }
237
238 pub fn clear_entities(&self) -> Result<()> {
240 self.conn.execute("DELETE FROM entities", [])
241 .context("清空 FTS5 表失败")?;
242 Ok(())
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::model::{NodeId, NodeKind};
250
251 fn tmp_db_path(label: &str) -> std::path::PathBuf {
252 let mut p = std::env::temp_dir();
253 p.push(format!("store_test_{}_{}.db", label, std::process::id()));
254 let _ = std::fs::remove_file(&p);
255 p
256 }
257
258 fn make_node(name: &str, file: &str) -> CodeNode {
259 CodeNode {
260 id: NodeId::new(0),
261 kind: NodeKind::Function,
262 name: name.into(),
263 file_path: Some(file.into()),
264 line_range: Some((1, 10)),
265 doc_comment: None,
266 signature: Some(format!("fn {}()", name)), visibility: None,
267 module_path: vec![],
268 }
269 }
270
271 #[test]
272 fn test_fts_insert_and_search() {
273 let (store, need_reindex) = SearchStore::open(tmp_db_path("fts")).unwrap();
274 assert!(!need_reindex, "新库无需重建");
275 let items = vec![
276 (make_node("authenticate", "src/auth.rs"), "fn authenticate(user: &str)".to_string()),
277 (make_node("save_session", "src/storage.rs"), "fn save_session(id: u64)".to_string()),
278 ];
279 store.insert_entities_batch(&items).unwrap();
280 assert_eq!(store.entity_count().unwrap(), 2);
281
282 let results = store.search_fts("authenticate", 5).unwrap();
283 assert!(!results.is_empty());
284 assert_eq!(results[0].0.name, "authenticate");
285 }
286
287 #[test]
288 fn test_fts_delete_by_file() {
289 let (store, _) = SearchStore::open(tmp_db_path("fts_del")).unwrap();
290 let items = vec![
291 (make_node("alpha", "src/a.rs"), "alpha code".to_string()),
292 (make_node("beta", "src/b.rs"), "beta code".to_string()),
293 ];
294 store.insert_entities_batch(&items).unwrap();
295
296 let removed = store.delete_entities_by_file("src/a.rs").unwrap();
297 assert_eq!(removed, 1);
298 assert_eq!(store.entity_count().unwrap(), 1);
299 }
300
301 #[test]
303 fn test_fts_cjk_substring_search() {
304 let (store, _) = SearchStore::open(tmp_db_path("fts_cjk")).unwrap();
305 let items = vec![
306 (make_node("提取配置", "src/config.rs"), "fn 提取配置() 读取合并后的配置".to_string()),
307 (make_node("save_session", "src/storage.rs"), "fn save_session(id: u64)".to_string()),
308 ];
309 store.insert_entities_batch(&items).unwrap();
310
311 let results = store.search_fts("配置", 5).unwrap();
314 assert_eq!(results.len(), 1, "中文 2-gram 应命中实体");
315 assert_eq!(results[0].0.name, "提取配置");
316
317 let mixed = store.search_fts("配置 session", 5).unwrap();
319 assert_eq!(mixed.len(), 2, "混合查询应同时命中中文与英文实体");
320 }
321
322 #[test]
325 fn test_fts_legacy_schema_migration() {
326 let path = tmp_db_path("fts_migrate");
327 let _ = std::fs::remove_file(&path);
328 {
329 let conn = rusqlite::Connection::open(&path).unwrap();
331 conn.execute_batch(
332 "CREATE VIRTUAL TABLE entities USING fts5(
333 name, kind, signature, source, file_path, node_json
334 );"
335 ).unwrap();
336 conn.pragma_update(None, "user_version", 1).unwrap();
337 conn.execute(
338 "INSERT INTO entities (name, kind, signature, source, file_path, node_json)
339 VALUES ('old_fn', 'function', 'fn old_fn()', 'old code', 'src/old.rs', '{}')",
340 [],
341 ).unwrap();
342 }
343
344 let (store, need_reindex) = SearchStore::open(&path).unwrap();
346 assert!(need_reindex, "旧 schema 必须触发重建标记");
347 assert_eq!(store.entity_count().unwrap(), 0, "旧索引数据已清空");
348
349 let items = vec![
351 (make_node("验证迁移", "src/new.rs"), "fn 验证迁移()".to_string()),
352 ];
353 store.insert_entities_batch(&items).unwrap();
354 let results = store.search_fts("迁移", 5).unwrap();
355 assert_eq!(results.len(), 1, "迁移后 v2 检索正常");
356 assert_eq!(results[0].0.name, "验证迁移");
357
358 let (_, need_reindex2) = SearchStore::open(&path).unwrap();
360 assert!(!need_reindex2, "二次 open 不应再触发重建");
361 }
362
363 #[test]
365 fn test_fts_punctuation_query_returns_empty() {
366 let (store, _) = SearchStore::open(tmp_db_path("fts_punct")).unwrap();
367 let items = vec![(make_node("alpha", "src/a.rs"), "alpha code".to_string())];
368 store.insert_entities_batch(&items).unwrap();
369
370 let empty = store.search_fts("", 5).unwrap();
371 assert!(empty.is_empty(), "空串查询返回空");
372 let punct = store.search_fts("!!!---", 5).unwrap();
373 assert!(punct.is_empty(), "纯标点查询返回空(v1 会语法错误上抛)");
374 }
375}