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)>, pub callers: Vec<(String, String, String)>,
10 pub callees: Vec<(String, String, String)>,
11 pub fallback: bool,
12}
13
14pub 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 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 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 if !w.chars().all(|c| c.is_alphanumeric() || c == '_') {
44 continue;
45 }
46 if w.chars().any(|c| c.is_uppercase() || c == '_') || w.len() >= 4 {
48 push(w);
49 }
50 }
51
52 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
61pub 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 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 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 g.add_edge("caller_id", "handler_id", "calls", "high", None);
157 g.add_edge("handler_id", "callee_id", "calls", "high", None);
158 g.add_edge("unrelated_id", "handler_id", "method", "high", None);
160 g
161 }
162
163 #[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 assert!(!c.contains(&"it".to_string()));
202 }
203
204 #[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 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 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 #[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 #[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 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}