Skip to main content

codesynapse_serve/
context.rs

1use std::collections::{HashMap, HashSet};
2
3use codesynapse_core::embedding::StaticEmbedder;
4
5use crate::graph_query::{query_top_nodes, ServeGraph};
6
7pub struct ContextResult {
8    pub entry_points: Vec<(String, String, String)>, // (id, label, source_file)
9    pub callers: Vec<(String, String, String)>,
10    pub callees: Vec<(String, String, String)>,
11    pub fallback: bool,
12}
13
14/// Extract symbol-like candidates from a natural-language query for exact label matching.
15pub fn extract_symbol_candidates(query: &str) -> Vec<String> {
16    let mut seen: HashSet<String> = HashSet::new();
17    let mut out: Vec<String> = Vec::new();
18
19    let mut push = |s: &str| {
20        if !s.is_empty() && seen.insert(s.to_string()) {
21            out.push(s.to_string());
22        }
23    };
24
25    let trimmed = query.trim();
26
27    // Dot notation (single token like "django.db.models"): split on dots
28    if trimmed.contains('.') && !trimmed.contains(' ') {
29        for part in trimmed.split('.') {
30            let p = part.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
31            push(p);
32        }
33        return out;
34    }
35
36    // Per-word extraction
37    for word in trimmed.split_whitespace() {
38        let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
39        if w.len() < 2 {
40            continue;
41        }
42        // Must be a valid identifier (alphanum + underscore only)
43        if !w.chars().all(|c| c.is_alphanumeric() || c == '_') {
44            continue;
45        }
46        // Include if it looks like code: has uppercase, has underscore, or is 4+ chars
47        if w.chars().any(|c| c.is_uppercase() || c == '_') || w.len() >= 4 {
48            push(w);
49        }
50    }
51
52    // Single token: always include the full trimmed value
53    if !trimmed.contains(' ') && !trimmed.is_empty() {
54        let cleaned = trimmed.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
55        push(cleaned);
56    }
57
58    out
59}
60
61/// Exact-symbol lookup + 1-hop call-graph expansion.
62///
63/// Returns matched entry points with their callers and callees (via `calls` edges only).
64/// Falls back to semantic search if no exact label match is found.
65pub fn context_query(
66    g: &ServeGraph,
67    query: &str,
68    dense: Option<(&StaticEmbedder, &HashMap<String, Vec<f32>>)>,
69) -> ContextResult {
70    let candidates = extract_symbol_candidates(query);
71
72    // Exact label match (case-insensitive); strip "()" suffix from function labels.
73    // When query contains long identifiers (≥10 chars), use only those for matching —
74    // drops generic noise like "task"/"state" that would otherwise flood results.
75    let max_candidate_len = candidates.iter().map(|c| c.len()).max().unwrap_or(0);
76    let effective_candidates: Vec<&String> = if max_candidate_len >= 10 {
77        candidates.iter().filter(|c| c.len() >= 6).collect()
78    } else {
79        candidates.iter().collect()
80    };
81
82    let entry_ids: Vec<String> = g
83        .nodes_iter()
84        .filter(|(_, n)| {
85            let lc_label = n.label.to_lowercase();
86            let bare_label = lc_label.strip_suffix("()").unwrap_or(&lc_label);
87            effective_candidates.iter().any(|c| {
88                let lc_c = c.to_lowercase();
89                lc_c == lc_label || lc_c == bare_label
90            })
91        })
92        .map(|(id, _)| id.to_string())
93        .collect();
94
95    if entry_ids.is_empty() {
96        let fallback_nodes = query_top_nodes(g, query, 5, dense);
97        return ContextResult {
98            entry_points: fallback_nodes,
99            callers: Vec::new(),
100            callees: Vec::new(),
101            fallback: true,
102        };
103    }
104
105    let entry_set: HashSet<String> = entry_ids.iter().cloned().collect();
106
107    // Collect calls edges once, avoid holding borrow across node lookups
108    let call_edges: Vec<(String, String)> = g
109        .edges_iter()
110        .filter(|e| e.relation == "calls")
111        .map(|e| (e.source.clone(), e.target.clone()))
112        .collect();
113
114    let mut caller_ids: HashSet<String> = HashSet::new();
115    let mut callee_ids: HashSet<String> = HashSet::new();
116
117    for (src, tgt) in &call_edges {
118        if entry_set.contains(tgt) && !entry_set.contains(src) {
119            caller_ids.insert(src.clone());
120        }
121        if entry_set.contains(src) && !entry_set.contains(tgt) {
122            callee_ids.insert(tgt.clone());
123        }
124    }
125
126    let to_tuple = |id: &str| -> Option<(String, String, String)> {
127        g.get_node(id)
128            .map(|n| (id.to_string(), n.label.clone(), n.source_file.clone()))
129    };
130
131    let mut ep_seen: HashSet<(String, String)> = HashSet::new();
132    ContextResult {
133        entry_points: entry_ids
134            .iter()
135            .filter_map(|id| to_tuple(id))
136            .filter(|(_, label, sf)| ep_seen.insert((label.clone(), sf.clone())))
137            .collect(),
138        callers: caller_ids.iter().filter_map(|id| to_tuple(id)).collect(),
139        callees: callee_ids.iter().filter_map(|id| to_tuple(id)).collect(),
140        fallback: false,
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::graph_query::ServeGraph;
148
149    fn make_graph() -> ServeGraph {
150        let mut g = ServeGraph::new_directed();
151        g.add_node("handler_id", "QueryHandler", "app/handlers.py", "", None);
152        g.add_node("caller_id", "dispatch", "app/router.py", "", None);
153        g.add_node("callee_id", "fetch_data", "app/db.py", "", None);
154        g.add_node("unrelated_id", "SomeOther", "app/other.py", "", None);
155        // calls edges
156        g.add_edge("caller_id", "handler_id", "calls", "high", None);
157        g.add_edge("handler_id", "callee_id", "calls", "high", None);
158        // non-calls edge: should NOT appear in 1-hop expansion
159        g.add_edge("unrelated_id", "handler_id", "method", "high", None);
160        g
161    }
162
163    // --- extract_symbol_candidates ---
164
165    #[test]
166    fn test_candidates_camelcase() {
167        let c = extract_symbol_candidates("where is QueryHandler");
168        assert!(c.contains(&"QueryHandler".to_string()));
169    }
170
171    #[test]
172    fn test_candidates_snake_case() {
173        let c = extract_symbol_candidates("full_text_search");
174        assert!(c.contains(&"full_text_search".to_string()));
175    }
176
177    #[test]
178    fn test_candidates_single_token() {
179        let c = extract_symbol_candidates("QueryHandler");
180        assert!(c.contains(&"QueryHandler".to_string()));
181    }
182
183    #[test]
184    fn test_candidates_dot_notation() {
185        let c = extract_symbol_candidates("django.db.models");
186        assert!(c.contains(&"django".to_string()));
187        assert!(c.contains(&"db".to_string()));
188        assert!(c.contains(&"models".to_string()));
189    }
190
191    #[test]
192    fn test_candidates_all_caps() {
193        let c = extract_symbol_candidates("what is BM25Index");
194        assert!(c.contains(&"BM25Index".to_string()));
195    }
196
197    #[test]
198    fn test_candidates_excludes_short_stop_words() {
199        let c = extract_symbol_candidates("how is it done");
200        // "it" (2 chars, no uppercase/underscore) should be excluded
201        assert!(!c.contains(&"it".to_string()));
202    }
203
204    // --- context_query ---
205
206    #[test]
207    fn test_exact_match_function_without_parens() {
208        let mut g = ServeGraph::new_directed();
209        g.add_node(
210            "fn_id",
211            "notify_should_wakeup()",
212            "runtime/idle.rs",
213            "",
214            None,
215        );
216        let r = context_query(&g, "notify_should_wakeup", None);
217        assert!(
218            !r.fallback,
219            "function query without () should match label with ()"
220        );
221        assert_eq!(r.entry_points.len(), 1);
222        assert_eq!(r.entry_points[0].1, "notify_should_wakeup()");
223    }
224
225    #[test]
226    fn test_long_identifier_filters_out_short_noise() {
227        let mut g = ServeGraph::new_directed();
228        g.add_node(
229            "fn_id",
230            "transition_to_notified_by_val()",
231            "state.rs",
232            "",
233            None,
234        );
235        g.add_node("task1", "task", "task1.rs", "", None);
236        g.add_node("task2", "task", "task2.rs", "", None);
237        // "task" (4 chars) should be filtered when "transition_to_notified_by_val" (33 chars) present
238        let r = context_query(&g, "transition_to_notified_by_val task state", None);
239        assert!(!r.fallback);
240        assert_eq!(
241            r.entry_points.len(),
242            1,
243            "only specific function, not generic 'task' nodes"
244        );
245        assert_eq!(r.entry_points[0].1, "transition_to_notified_by_val()");
246    }
247
248    #[test]
249    fn test_exact_match_entry_points() {
250        let g = make_graph();
251        let r = context_query(&g, "QueryHandler", None);
252        assert!(!r.fallback);
253        assert_eq!(r.entry_points.len(), 1);
254        assert_eq!(r.entry_points[0].1, "QueryHandler");
255    }
256
257    #[test]
258    fn test_exact_match_callers() {
259        let g = make_graph();
260        let r = context_query(&g, "QueryHandler", None);
261        assert_eq!(r.callers.len(), 1);
262        assert_eq!(r.callers[0].1, "dispatch");
263    }
264
265    #[test]
266    fn test_exact_match_callees() {
267        let g = make_graph();
268        let r = context_query(&g, "QueryHandler", None);
269        assert_eq!(r.callees.len(), 1);
270        assert_eq!(r.callees[0].1, "fetch_data");
271    }
272
273    #[test]
274    fn test_calls_only_not_method_edges() {
275        let g = make_graph();
276        let r = context_query(&g, "QueryHandler", None);
277        // "SomeOther" connects via "method" edge — must NOT appear in callers
278        assert!(!r.callers.iter().any(|(_, label, _)| label == "SomeOther"));
279    }
280
281    #[test]
282    fn test_case_insensitive_match() {
283        let g = make_graph();
284        let r = context_query(&g, "queryhandler", None);
285        assert!(!r.fallback);
286        assert_eq!(r.entry_points.len(), 1);
287        assert_eq!(r.entry_points[0].1, "QueryHandler");
288    }
289
290    #[test]
291    fn test_no_match_returns_fallback() {
292        let g = make_graph();
293        let r = context_query(&g, "nonexistent_xyz_symbol", None);
294        assert!(r.fallback);
295        assert!(r.callers.is_empty());
296        assert!(r.callees.is_empty());
297    }
298
299    #[test]
300    fn test_entry_point_not_in_its_own_callers_or_callees() {
301        let g = make_graph();
302        let r = context_query(&g, "QueryHandler", None);
303        assert!(!r.callers.iter().any(|(_, l, _)| l == "QueryHandler"));
304        assert!(!r.callees.iter().any(|(_, l, _)| l == "QueryHandler"));
305    }
306
307    // --- Fix 2: dedup entry_points by (label, source_file) ---
308
309    #[test]
310    fn test_dedup_same_label_same_file() {
311        let mut g = ServeGraph::new_directed();
312        g.add_node("auth1", "authenticate", "auth/backends.py", "", None);
313        g.add_node("auth2", "authenticate", "auth/backends.py", "", None);
314        g.add_node("auth3", "authenticate", "auth/backends.py", "", None);
315        let r = context_query(&g, "authenticate", None);
316        assert!(!r.fallback);
317        assert_eq!(
318            r.entry_points.len(),
319            1,
320            "duplicate (label, source_file) pairs should be deduped to one"
321        );
322    }
323
324    #[test]
325    fn test_dedup_same_label_different_file() {
326        let mut g = ServeGraph::new_directed();
327        g.add_node("auth1", "authenticate", "module_a/backends.py", "", None);
328        g.add_node("auth2", "authenticate", "module_b/backends.py", "", None);
329        let r = context_query(&g, "authenticate", None);
330        assert!(!r.fallback);
331        assert_eq!(
332            r.entry_points.len(),
333            2,
334            "same label but different source files should both appear"
335        );
336    }
337
338    // --- Fix 3: length filter >= 8 → >= 6 ---
339
340    #[test]
341    fn test_filter_keeps_six_char_module_name() {
342        let mut g = ServeGraph::new_directed();
343        g.add_node("mod", "django", "app.py", "", None);
344        g.add_node("auth", "authentication", "auth.py", "", None);
345        let r = context_query(&g, "django authentication", None);
346        assert!(!r.fallback);
347        let labels: Vec<&str> = r.entry_points.iter().map(|(_, l, _)| l.as_str()).collect();
348        assert!(
349            labels.contains(&"django"),
350            "'django' (6 chars) should be kept by filter; got: {:?}",
351            labels
352        );
353    }
354
355    #[test]
356    fn test_filter_drops_five_char_stopword() {
357        let mut g = ServeGraph::new_directed();
358        g.add_node("where_node", "where", "app.py", "", None);
359        g.add_node("handler", "QueryHandler", "handlers.py", "", None);
360        let r = context_query(&g, "where is QueryHandler", None);
361        let labels: Vec<&str> = r.entry_points.iter().map(|(_, l, _)| l.as_str()).collect();
362        assert!(
363            !labels.contains(&"where"),
364            "'where' (5 chars) should still be filtered out; got: {:?}",
365            labels
366        );
367        assert!(
368            labels.contains(&"QueryHandler"),
369            "'QueryHandler' should be matched; got: {:?}",
370            labels
371        );
372    }
373
374    #[test]
375    fn test_filter_not_applied_when_max_below_ten() {
376        let mut g = ServeGraph::new_directed();
377        g.add_node("u", "User", "models.py", "", None);
378        g.add_node("l", "login", "auth.py", "", None);
379        // "User" (4) + "login" (5) → max = 5 < 10, no filter applies
380        let r = context_query(&g, "User login", None);
381        assert!(!r.fallback);
382        let labels: Vec<&str> = r.entry_points.iter().map(|(_, l, _)| l.as_str()).collect();
383        assert!(
384            labels.contains(&"User"),
385            "'User' should be kept; got: {:?}",
386            labels
387        );
388        assert!(
389            labels.contains(&"login"),
390            "'login' should be kept; got: {:?}",
391            labels
392        );
393    }
394}