Skip to main content

the_code_graph_storage/
search_index.rs

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        // Cap query length to prevent DoS via excessive FTS5 tokenization.
15        // Truncate at a valid UTF-8 char boundary to avoid panics on multi-byte input.
16        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        // SAFETY INVARIANT: wrapping in double-quotes makes FTS5 treat the input as a
30        // literal phrase, disabling operators (AND, OR, NOT, column filters, ^).
31        // Internal double-quotes are escaped by doubling ("" → "). This invariant MUST
32        // be preserved — removing the outer quotes would re-enable FTS5 syntax injection.
33        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, // FTS5 rank is negative (lower = better), invert for display
68                score_source: None,
69            });
70        }
71        Ok(results)
72    }
73
74    fn index_symbol(&self, _symbol: &SymbolNode) -> Result<()> {
75        // No-op: FTS5 triggers handle sync automatically
76        Ok(())
77    }
78
79    fn rebuild(&self) -> Result<()> {
80        // Stub: real rebuild deferred to when needed
81        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        // Insert with a unique name that only appears in the name field
156        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        // Update name — FTS5 trigger should remove old entry and add new one
178        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}