1use std::collections::HashSet;
17
18use gitcortex_core::{error::Result, graph::Node, schema::NodeKind, store::GraphStore};
19use serde::Serialize;
20
21#[derive(Debug, Clone, Serialize)]
22pub struct SearchHit {
23 pub name: String,
24 pub qualified_name: String,
25 pub kind: String,
26 pub file: String,
27 pub start_line: u32,
28 pub score: i32,
29}
30
31#[derive(Debug, Clone, Serialize)]
34pub struct FileGroup {
35 pub file: String,
36 pub symbol_count: usize,
37 pub top_symbols: Vec<String>,
39}
40
41pub fn group_by_file(hits: &[SearchHit]) -> Vec<FileGroup> {
47 let mut order: Vec<String> = Vec::new();
49 let mut map: std::collections::HashMap<String, (usize, Vec<String>)> =
50 std::collections::HashMap::new();
51
52 for h in hits {
53 let e = map.entry(h.file.clone()).or_insert_with(|| {
54 order.push(h.file.clone());
55 (0, Vec::new())
56 });
57 e.0 += 1;
58 if e.1.len() < 3 {
59 e.1.push(h.name.clone());
60 }
61 }
62
63 let mut groups: Vec<FileGroup> = order
64 .into_iter()
65 .map(|file| {
66 let (count, top) = map.remove(&file).unwrap();
67 FileGroup {
68 file,
69 symbol_count: count,
70 top_symbols: top,
71 }
72 })
73 .collect();
74
75 groups.sort_by(|a, b| {
76 b.symbol_count
77 .cmp(&a.symbol_count)
78 .then_with(|| a.file.cmp(&b.file))
79 });
80 groups
81}
82
83const DEFAULT_LIMIT: usize = 10;
84const MAX_LIMIT: usize = 200;
85const MIN_TOKEN_LEN: usize = 3;
86
87pub(crate) fn tokenize(s: &str) -> Vec<String> {
94 let mut tokens = Vec::new();
95 let mut current = String::new();
96 let chars: Vec<char> = s.chars().collect();
97 for (i, &ch) in chars.iter().enumerate() {
98 if ch == '_' || ch == '-' || ch == '.' || ch == ':' || ch == '/' || ch == ' ' {
99 if !current.is_empty() {
100 tokens.push(current.to_ascii_lowercase());
101 current = String::new();
102 }
103 } else if ch.is_uppercase() {
104 let next_is_lower = chars.get(i + 1).map(|c| c.is_lowercase()).unwrap_or(false);
107 let prev_is_upper = i > 0 && chars[i - 1].is_uppercase();
108 if !current.is_empty() && (!prev_is_upper || next_is_lower) {
109 tokens.push(current.to_ascii_lowercase());
110 current = String::new();
111 }
112 current.push(ch.to_ascii_lowercase());
113 } else {
114 current.push(ch);
115 }
116 }
117 if !current.is_empty() {
118 tokens.push(current.to_ascii_lowercase());
119 }
120 tokens
121}
122
123fn edit_distance(a: &str, b: &str) -> usize {
125 let a: Vec<char> = a.chars().collect();
126 let b: Vec<char> = b.chars().collect();
127 let m = a.len();
128 let n = b.len();
129 if m.abs_diff(n) > 3 {
131 return usize::MAX;
132 }
133 let mut prev: Vec<usize> = (0..=n).collect();
134 let mut curr = vec![0usize; n + 1];
135 for i in 1..=m {
136 curr[0] = i;
137 for j in 1..=n {
138 curr[j] = if a[i - 1] == b[j - 1] {
139 prev[j - 1]
140 } else {
141 1 + prev[j - 1].min(prev[j]).min(curr[j - 1])
142 };
143 }
144 std::mem::swap(&mut prev, &mut curr);
145 }
146 prev[n]
147}
148
149fn score(n: &Node, q_lower: &str, q_tokens: &[String]) -> Option<i32> {
151 let name_lower = n.name.to_ascii_lowercase();
152 let qname_lower = n.qualified_name.to_ascii_lowercase();
153 let name_tokens = tokenize(&n.name);
154
155 let base = if name_lower == q_lower {
156 100
158 } else if name_lower.starts_with(q_lower) {
159 60
160 } else if !q_tokens.is_empty() && q_tokens.iter().all(|t| name_tokens.contains(t)) {
161 50
164 } else if name_lower.contains(q_lower) {
165 30
166 } else {
167 let overlap = q_tokens
169 .iter()
170 .filter(|qt| qt.len() >= MIN_TOKEN_LEN && name_tokens.contains(*qt))
171 .count();
172 if overlap > 0 {
173 10 + (overlap as i32 * 5).min(15)
174 } else if qname_lower.contains(q_lower) {
175 10
177 } else if q_lower.len() >= 4 && q_lower.len() <= 15 && name_lower.len() <= 25 {
178 let dist = edit_distance(q_lower, &name_lower);
180 if dist <= 1 {
181 20
182 } else if dist <= 2 {
183 10
184 } else {
185 return None;
186 }
187 } else {
188 return None;
189 }
190 };
191
192 Some(base + kind_boost(&n.kind))
193}
194
195fn kind_boost(k: &NodeKind) -> i32 {
196 match k {
197 NodeKind::Function | NodeKind::Method => 5,
198 NodeKind::Struct | NodeKind::Trait | NodeKind::Interface => 4,
199 NodeKind::Enum | NodeKind::TypeAlias => 3,
200 NodeKind::Constant | NodeKind::Macro | NodeKind::Annotation => 2,
201 _ => 0,
202 }
203}
204
205fn to_hit(n: Node, score: i32) -> SearchHit {
206 SearchHit {
207 name: n.name,
208 qualified_name: n.qualified_name,
209 kind: n.kind.to_string(),
210 file: n.file.display().to_string(),
211 start_line: n.span.start_line,
212 score,
213 }
214}
215
216pub fn search<S: GraphStore + ?Sized>(
223 store: &S,
224 branch: &str,
225 query: &str,
226 limit: Option<usize>,
227) -> Result<Vec<SearchHit>> {
228 let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
229 let q = query.trim();
230 if q.is_empty() {
231 return Ok(Vec::new());
232 }
233
234 let q_lower = q.to_ascii_lowercase();
235 let q_tokens = tokenize(q);
236 let candidate_limit = (limit * 50).max(500);
237
238 let mut seen: HashSet<String> = HashSet::new();
240 let mut nodes: Vec<Node> = Vec::new();
241
242 let push = |nodes: &mut Vec<Node>, seen: &mut HashSet<String>, batch: Vec<Node>| {
243 for n in batch {
244 let id = n.id.as_str();
245 if seen.insert(id) {
246 nodes.push(n);
247 }
248 }
249 };
250
251 push(
252 &mut nodes,
253 &mut seen,
254 store.search_nodes(branch, q, candidate_limit)?,
255 );
256
257 for token in &q_tokens {
260 if token.len() < MIN_TOKEN_LEN {
261 continue;
262 }
263 if token.as_str() == q_lower {
265 continue;
266 }
267 push(
268 &mut nodes,
269 &mut seen,
270 store.search_nodes(branch, token, candidate_limit)?,
271 );
272 }
273
274 if nodes.is_empty() && q_lower.len() >= 4 && q_lower.len() <= 20 {
279 push(&mut nodes, &mut seen, store.list_all_nodes(branch)?);
280 }
281
282 let mut hits: Vec<SearchHit> = nodes
283 .into_iter()
284 .filter_map(|n| score(&n, &q_lower, &q_tokens).map(|s| to_hit(n, s)))
285 .collect();
286
287 hits.sort_by(|a, b| {
288 b.score
289 .cmp(&a.score)
290 .then_with(|| a.name.len().cmp(&b.name.len()))
291 .then_with(|| a.qualified_name.cmp(&b.qualified_name))
292 });
293 hits.truncate(limit);
294 Ok(hits)
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn tokenize_camel_case() {
303 assert_eq!(tokenize("AuthConfig"), vec!["auth", "config"]);
304 assert_eq!(tokenize("validateToken"), vec!["validate", "token"]);
305 assert_eq!(tokenize("HTTPClient"), vec!["http", "client"]);
306 }
307
308 #[test]
309 fn tokenize_snake_case() {
310 assert_eq!(tokenize("validate_token"), vec!["validate", "token"]);
311 assert_eq!(tokenize("auth_middleware"), vec!["auth", "middleware"]);
312 }
313
314 #[test]
315 fn tokenize_pascal_case() {
316 assert_eq!(tokenize("KuzuGraphStore"), vec!["kuzu", "graph", "store"]);
317 }
318
319 #[test]
320 fn edit_distance_exact() {
321 assert_eq!(edit_distance("validate", "validate"), 0);
322 }
323
324 #[test]
325 fn edit_distance_typo() {
326 assert_eq!(edit_distance("vlidate", "validate"), 1);
327 assert_eq!(edit_distance("authnticate", "authenticate"), 1);
328 }
329
330 #[test]
331 fn edit_distance_length_short_circuit() {
332 assert_eq!(edit_distance("a", "abcde"), usize::MAX);
334 }
335
336 fn hit(name: &str, file: &str, score: i32) -> SearchHit {
339 SearchHit {
340 name: name.to_owned(),
341 qualified_name: name.to_owned(),
342 kind: "Function".to_owned(),
343 file: file.to_owned(),
344 start_line: 1,
345 score,
346 }
347 }
348
349 #[test]
350 fn group_by_file_empty_returns_empty() {
351 assert!(group_by_file(&[]).is_empty());
352 }
353
354 #[test]
355 fn group_by_file_single_file() {
356 let hits = vec![hit("parse_args", "src/parser.rs", 100)];
357 let groups = group_by_file(&hits);
358 assert_eq!(groups.len(), 1);
359 assert_eq!(groups[0].file, "src/parser.rs");
360 assert_eq!(groups[0].symbol_count, 1);
361 assert_eq!(groups[0].top_symbols, vec!["parse_args"]);
362 }
363
364 #[test]
365 fn group_by_file_sorted_by_count_desc() {
366 let hits = vec![
367 hit("a", "src/big.rs", 80),
368 hit("b", "src/big.rs", 70),
369 hit("c", "src/big.rs", 60),
370 hit("x", "src/small.rs", 50),
371 ];
372 let groups = group_by_file(&hits);
373 assert_eq!(groups[0].file, "src/big.rs");
374 assert_eq!(groups[0].symbol_count, 3);
375 assert_eq!(groups[1].file, "src/small.rs");
376 assert_eq!(groups[1].symbol_count, 1);
377 }
378
379 #[test]
380 fn group_by_file_top_symbols_capped_at_three() {
381 let hits: Vec<SearchHit> = (0..8)
382 .map(|i| hit(&format!("fn{i}"), "src/big.rs", 100 - i))
383 .collect();
384 let groups = group_by_file(&hits);
385 assert_eq!(groups[0].symbol_count, 8);
386 assert_eq!(groups[0].top_symbols.len(), 3);
387 assert_eq!(groups[0].top_symbols[0], "fn0");
389 }
390
391 #[test]
392 fn group_by_file_alphabetical_tie_break() {
393 let hits = vec![hit("a", "src/z.rs", 50), hit("b", "src/a.rs", 50)];
394 let groups = group_by_file(&hits);
395 assert_eq!(groups[0].file, "src/a.rs");
397 }
398}