Skip to main content

gitcortex_mcp/mcp/
search.rs

1//! Fuzzy search over the graph — multi-signal ranking with CamelCase/snake_case
2//! tokenisation, token overlap scoring, and edit-distance typo tolerance.
3//!
4//! Ranking signals (higher score = better match):
5//! - exact name match:                   +100
6//! - prefix name match:                  +60
7//! - all query tokens match name tokens: +50
8//! - substring in name:                  +30
9//! - partial token overlap:              +10..+25
10//! - edit distance ≤1 (typo):            +20
11//! - edit distance ≤2:                   +10
12//! - substring in qualified_name only:   +10
13//! - shorter names break ties
14//! - kind boost: Function/Method/Struct/Trait > others
15
16use 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/// Hits grouped by file — lets a model see "parse is in parser.rs (8 symbols)"
32/// at a glance without iterating the full flat hit list.
33#[derive(Debug, Clone, Serialize)]
34pub struct FileGroup {
35    pub file: String,
36    pub symbol_count: usize,
37    /// Up to 3 highest-scoring symbol names in this file.
38    pub top_symbols: Vec<String>,
39}
40
41/// Group search hits by file. Groups are sorted by `symbol_count` descending
42/// (most hits first), ties broken alphabetically by file path.
43///
44/// Input must already be sorted by score descending so `top_symbols` picks the
45/// highest-scoring symbols per file without extra sorting.
46pub fn group_by_file(hits: &[SearchHit]) -> Vec<FileGroup> {
47    // Preserve insertion order (first-seen = highest-scored) per file.
48    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
87/// Split a camelCase/snake_case/PascalCase identifier into lowercase tokens.
88///
89/// "AuthConfig"      → ["auth", "config"]
90/// "validate_token"  → ["validate", "token"]
91/// "parseJSONResponse" → ["parse", "j", "s", "o", "n", "response"]  (intentional — acronyms split per char)
92/// "HTTPClient"      → ["h", "t", "t", "p", "client"]
93pub(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            // Start new token on uppercase — but keep run of capitals together
105            // as one token (e.g. "HTTP" stays "http" not split per char).
106            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
123/// Levenshtein edit distance between two strings (capped early at `max`).
124fn 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    // Quick bounds: length difference alone is a lower bound.
130    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
149/// Score a node against a query. Returns `None` when the node is not a match.
150fn 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        // Exact name match — highest confidence.
157        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        // All query tokens present in name tokens.
162        // "auth config" fully matches "AuthConfig" or "auth_config".
163        50
164    } else if name_lower.contains(q_lower) {
165        30
166    } else {
167        // Partial token overlap.
168        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            // Match only in qualified path (e.g. module prefix).
176            10
177        } else if q_lower.len() >= 4 && q_lower.len() <= 15 && name_lower.len() <= 25 {
178            // Typo tolerance: edit distance on short-ish queries.
179            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
216/// Run a fuzzy search across all nodes on `branch`.
217///
218/// Candidate set is built by querying the store for the whole query string AND
219/// for each individual token (for multi-word / camelCase queries). Candidates
220/// are deduplicated, scored with the multi-signal scorer, sorted by score
221/// descending, and truncated to `limit`.
222pub 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    // Fetch candidates: whole query first, then per token.
239    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    // Per-token expansion: lets "validate token" find "validate_token" even
258    // when the store's CONTAINS filter requires the full substring.
259    for token in &q_tokens {
260        if token.len() < MIN_TOKEN_LEN {
261            continue;
262        }
263        // Skip if token equals the whole query (already fetched above).
264        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    // Typo-fallback: CONTAINS can't find misspelled queries ("Greetter" won't
275    // match "Greeter"). When no candidates found and query is short enough for
276    // edit-distance to be meaningful, scan all nodes so the scorer can apply
277    // typo tolerance.
278    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        // length difference > 3 → MAX
333        assert_eq!(edit_distance("a", "abcde"), usize::MAX);
334    }
335
336    // ── group_by_file ─────────────────────────────────────────────────────────
337
338    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        // First three are highest-scored (fn0, fn1, fn2).
388        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        // Both have count=1; alphabetical tie-break: a.rs before z.rs.
396        assert_eq!(groups[0].file, "src/a.rs");
397    }
398}