Skip to main content

gitcortex_mcp/mcp/
search.rs

1//! Fuzzy search over the graph — substring + qualified-path match with
2//! deterministic ranking. Wraps `GraphStore::lookup_symbol(fuzzy=true)` and
3//! a `list_all_nodes` scan to also match on `qualified_name`, then ranks.
4//!
5//! Ranking signal (higher score = better):
6//! - exact name match: +100
7//! - prefix name match: +60
8//! - substring in name: +30
9//! - substring in qualified_name only: +10
10//! - shorter names rank above longer when scores tie
11//! - kind boost: Function/Method/Struct/Trait > others
12
13use gitcortex_core::{error::Result, graph::Node, schema::NodeKind, store::GraphStore};
14use serde::Serialize;
15
16#[derive(Debug, Clone, Serialize)]
17pub struct SearchHit {
18    pub name: String,
19    pub qualified_name: String,
20    pub kind: String,
21    pub file: String,
22    pub start_line: u32,
23    pub score: i32,
24}
25
26/// Default result cap when caller does not specify.
27const DEFAULT_LIMIT: usize = 10;
28const MAX_LIMIT: usize = 200;
29
30/// Run a fuzzy search across all nodes of `branch`. The lookup matches both
31/// `name` and `qualified_name`. Results are deduped by node id and sorted by
32/// descending score.
33pub fn search<S: GraphStore + ?Sized>(
34    store: &S,
35    branch: &str,
36    query: &str,
37    limit: Option<usize>,
38) -> Result<Vec<SearchHit>> {
39    let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
40    let q = query.trim();
41    if q.is_empty() {
42        return Ok(Vec::new());
43    }
44
45    // Pull every node once and score in-process. Cheap: typical repo graphs
46    // fit comfortably in memory and we avoid N queries.
47    let nodes = store.list_all_nodes(branch)?;
48
49    let mut hits: Vec<SearchHit> = nodes
50        .into_iter()
51        .filter_map(|n| score(&n, q).map(|s| to_hit(n, s)))
52        .collect();
53
54    hits.sort_by(|a, b| {
55        b.score
56            .cmp(&a.score)
57            .then_with(|| a.name.len().cmp(&b.name.len()))
58            .then_with(|| a.qualified_name.cmp(&b.qualified_name))
59    });
60    hits.truncate(limit);
61    Ok(hits)
62}
63
64fn score(n: &Node, q: &str) -> Option<i32> {
65    // Case-insensitive match — fuzzy search UX expects "greet" to find both
66    // `greet` and `Greet`. Exact-case lookup is still available through
67    // `lookup_symbol` with `fuzzy=false`.
68    let q_lower = q.to_ascii_lowercase();
69    let name_lower = n.name.to_ascii_lowercase();
70    let qname_lower = n.qualified_name.to_ascii_lowercase();
71    let base = if name_lower == q_lower {
72        100
73    } else if name_lower.starts_with(&q_lower) {
74        60
75    } else if name_lower.contains(&q_lower) {
76        30
77    } else if qname_lower.contains(&q_lower) {
78        10
79    } else {
80        return None;
81    };
82    Some(base + kind_boost(&n.kind))
83}
84
85fn kind_boost(k: &NodeKind) -> i32 {
86    match k {
87        NodeKind::Function | NodeKind::Method => 5,
88        NodeKind::Struct | NodeKind::Trait | NodeKind::Interface => 4,
89        NodeKind::Enum | NodeKind::TypeAlias => 3,
90        NodeKind::Constant | NodeKind::Macro | NodeKind::Annotation => 2,
91        _ => 0,
92    }
93}
94
95fn to_hit(n: Node, score: i32) -> SearchHit {
96    SearchHit {
97        name: n.name,
98        qualified_name: n.qualified_name,
99        kind: n.kind.to_string(),
100        file: n.file.display().to_string(),
101        start_line: n.span.start_line,
102        score,
103    }
104}