1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use codesynapse_core::embedding::{cosine_similarity_f32, StaticEmbedder};
5use codesynapse_core::security::sanitize_label;
6
7const EXACT_MATCH_BONUS: f64 = 1000.0;
8const PREFIX_MATCH_BONUS: f64 = 100.0;
9const SUBSTRING_MATCH_BONUS: f64 = 1.0;
10const SOURCE_MATCH_BONUS: f64 = 0.5;
11pub const DEFAULT_MAX_GRAPH_FILE_BYTES: u64 = 512 * 1024 * 1024;
12
13pub struct ServeNode {
14 pub id: String,
15 pub label: String,
16 pub source_file: String,
17 pub source_location: String,
18 pub community: Option<i64>,
19 pub norm_label: Option<String>,
20 pub docstring: Option<String>,
21}
22
23pub struct ServeEdge {
24 pub source: String,
25 pub target: String,
26 pub relation: String,
27 pub confidence: String,
28 pub context: Option<String>,
29}
30
31pub struct ServeGraph {
32 nodes: HashMap<String, ServeNode>,
33 node_order: Vec<String>,
34 adj: HashMap<String, Vec<String>>,
35 edge_lookup: HashMap<(String, String), usize>,
36 edges: Vec<ServeEdge>,
37 pub idf_cache: HashMap<String, f64>,
38 directed: bool,
39 pub bm25_index: Option<Bm25Index>,
40}
41
42impl ServeGraph {
43 pub fn new_undirected() -> Self {
44 ServeGraph {
45 nodes: HashMap::new(),
46 node_order: Vec::new(),
47 adj: HashMap::new(),
48 edge_lookup: HashMap::new(),
49 edges: Vec::new(),
50 idf_cache: HashMap::new(),
51 directed: false,
52 bm25_index: None,
53 }
54 }
55
56 pub fn build_bm25_index(&mut self) {
57 self.bm25_index = Some(Bm25Index::build(self));
58 }
59
60 pub fn bm25(&self) -> Option<&Bm25Index> {
61 self.bm25_index.as_ref()
62 }
63
64 pub fn new_directed() -> Self {
65 ServeGraph {
66 directed: true,
67 ..ServeGraph::new_undirected()
68 }
69 }
70
71 pub fn add_node(
72 &mut self,
73 id: &str,
74 label: &str,
75 source_file: &str,
76 source_location: &str,
77 community: Option<i64>,
78 ) {
79 if !self.nodes.contains_key(id) {
80 self.node_order.push(id.to_string());
81 self.adj.entry(id.to_string()).or_default();
82 }
83 self.nodes.insert(
84 id.to_string(),
85 ServeNode {
86 id: id.to_string(),
87 label: label.to_string(),
88 source_file: source_file.to_string(),
89 source_location: source_location.to_string(),
90 community,
91 norm_label: None,
92 docstring: None,
93 },
94 );
95 }
96
97 pub fn add_edge(
98 &mut self,
99 source: &str,
100 target: &str,
101 relation: &str,
102 confidence: &str,
103 context: Option<&str>,
104 ) {
105 self.adj.entry(source.to_string()).or_default();
107 self.adj.entry(target.to_string()).or_default();
108
109 let idx = self.edges.len();
110 self.edges.push(ServeEdge {
111 source: source.to_string(),
112 target: target.to_string(),
113 relation: relation.to_string(),
114 confidence: confidence.to_string(),
115 context: context.map(str::to_string),
116 });
117 self.edge_lookup
118 .insert((source.to_string(), target.to_string()), idx);
119
120 self.adj
122 .entry(source.to_string())
123 .or_default()
124 .push(target.to_string());
125
126 if !self.directed {
127 self.adj
129 .entry(target.to_string())
130 .or_default()
131 .push(source.to_string());
132 self.edge_lookup
134 .insert((target.to_string(), source.to_string()), idx);
135 }
136 }
137
138 pub fn neighbors(&self, n: &str) -> &[String] {
139 self.adj.get(n).map(|v| v.as_slice()).unwrap_or(&[])
140 }
141
142 pub fn degree(&self, n: &str) -> usize {
143 self.adj.get(n).map(|v| v.len()).unwrap_or(0)
144 }
145
146 pub fn num_nodes(&self) -> usize {
147 self.nodes.len()
148 }
149
150 pub fn num_edges(&self) -> usize {
151 if self.directed {
152 self.edges.len()
153 } else {
154 self.edges.len()
156 }
157 }
158
159 pub fn get_node(&self, id: &str) -> Option<&ServeNode> {
160 self.nodes.get(id)
161 }
162
163 pub fn get_edge_data(&self, u: &str, v: &str) -> Option<&ServeEdge> {
164 self.edge_lookup
165 .get(&(u.to_string(), v.to_string()))
166 .map(|&idx| &self.edges[idx])
167 }
168
169 pub fn nodes_iter(&self) -> impl Iterator<Item = (&str, &ServeNode)> {
170 self.node_order
171 .iter()
172 .filter_map(move |id| self.nodes.get(id).map(|n| (id.as_str(), n)))
173 }
174
175 pub fn edges_iter(&self) -> impl Iterator<Item = &ServeEdge> {
176 self.edges.iter()
177 }
178
179 pub fn contains_node(&self, id: &str) -> bool {
180 self.nodes.contains_key(id)
181 }
182
183 pub fn clone_nodes_only(&self) -> Self {
184 let mut g = ServeGraph {
185 nodes: HashMap::new(),
186 node_order: self.node_order.clone(),
187 adj: HashMap::new(),
188 edge_lookup: HashMap::new(),
189 edges: Vec::new(),
190 idf_cache: HashMap::new(),
191 directed: self.directed,
192 bm25_index: None,
193 };
194 for (id, n) in &self.nodes {
195 g.adj.entry(id.clone()).or_default();
196 g.nodes.insert(
197 id.clone(),
198 ServeNode {
199 id: n.id.clone(),
200 label: n.label.clone(),
201 source_file: n.source_file.clone(),
202 source_location: n.source_location.clone(),
203 community: n.community,
204 norm_label: n.norm_label.clone(),
205 docstring: n.docstring.clone(),
206 },
207 );
208 }
209 g
210 }
211}
212
213#[derive(Debug)]
217pub enum LoadError {
218 Io(String),
219 TooLarge { size: u64, cap: u64 },
220 Json(String),
221 NotJson,
222}
223
224impl std::fmt::Display for LoadError {
225 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 match self {
227 LoadError::Io(msg) => write!(f, "{}", msg),
228 LoadError::TooLarge { size, cap } => {
229 write!(f, "error: file size {} exceeds byte cap {}", size, cap)
230 }
231 LoadError::Json(msg) => write!(f, "error: {}", msg),
232 LoadError::NotJson => write!(f, "error: graph path must be a .json file"),
233 }
234 }
235}
236
237pub fn load_graph(path: &Path) -> Result<ServeGraph, LoadError> {
238 load_graph_with_cap(path, DEFAULT_MAX_GRAPH_FILE_BYTES)
239}
240
241pub fn load_graph_with_cap(path: &Path, max_bytes: u64) -> Result<ServeGraph, LoadError> {
242 if path.extension().and_then(|e| e.to_str()) != Some("json") {
243 return Err(LoadError::NotJson);
244 }
245 let meta = std::fs::metadata(path)
246 .map_err(|e| LoadError::Io(format!("Graph file not found: {}: {}", path.display(), e)))?;
247 let size = meta.len();
248 if size > max_bytes {
249 return Err(LoadError::TooLarge {
250 size,
251 cap: max_bytes,
252 });
253 }
254 let content = std::fs::read_to_string(path)
255 .map_err(|e| LoadError::Io(format!("Could not read {}: {}", path.display(), e)))?;
256 let v: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
257 LoadError::Json(format!(
258 "graph.json is corrupted ({}). Re-run to rebuild.",
259 e
260 ))
261 })?;
262
263 let directed = v.get("directed").and_then(|d| d.as_bool()).unwrap_or(false);
264 let mut g = if directed {
265 ServeGraph::new_directed()
266 } else {
267 ServeGraph::new_undirected()
268 };
269
270 if let Some(nodes) = v.get("nodes").and_then(|n| n.as_array()) {
271 for node in nodes {
272 let id = node
273 .get("id")
274 .and_then(|v| v.as_str())
275 .unwrap_or("")
276 .to_string();
277 let label = node
278 .get("label")
279 .and_then(|v| v.as_str())
280 .unwrap_or(&id)
281 .to_string();
282 let source_file = node
283 .get("source_file")
284 .and_then(|v| v.as_str())
285 .unwrap_or("")
286 .to_string();
287 let source_location = node
288 .get("source_location")
289 .and_then(|v| v.as_str())
290 .unwrap_or("")
291 .to_string();
292 let community = node.get("community").and_then(|c| c.as_i64());
293 if !id.is_empty() {
294 g.node_order.push(id.clone());
295 g.adj.entry(id.clone()).or_default();
296 let docstring = node
297 .get("docstring")
298 .and_then(|v| v.as_str())
299 .map(str::to_string);
300 g.nodes.insert(
301 id.clone(),
302 ServeNode {
303 id,
304 label,
305 source_file,
306 source_location,
307 community,
308 norm_label: None,
309 docstring,
310 },
311 );
312 }
313 }
314 }
315
316 let links_key = if v.get("links").is_some() {
318 "links"
319 } else {
320 "edges"
321 };
322 if let Some(links) = v.get(links_key).and_then(|l| l.as_array()) {
323 for link in links {
324 let source = link
325 .get("source")
326 .and_then(|v| v.as_str())
327 .unwrap_or("")
328 .to_string();
329 let target = link
330 .get("target")
331 .and_then(|v| v.as_str())
332 .unwrap_or("")
333 .to_string();
334 let relation = link
335 .get("relation")
336 .and_then(|v| v.as_str())
337 .unwrap_or("")
338 .to_string();
339 let confidence = link
340 .get("confidence")
341 .and_then(|v| v.as_str())
342 .unwrap_or("EXTRACTED")
343 .to_string();
344 let context = link
345 .get("context")
346 .and_then(|v| v.as_str())
347 .map(str::to_string);
348 if !source.is_empty() && !target.is_empty() {
349 let idx = g.edges.len();
350 g.edges.push(ServeEdge {
351 source: source.clone(),
352 target: target.clone(),
353 relation,
354 confidence,
355 context,
356 });
357 g.adj
358 .entry(source.clone())
359 .or_default()
360 .push(target.clone());
361 g.edge_lookup.insert((source.clone(), target.clone()), idx);
362 if !g.directed {
363 g.adj
364 .entry(target.clone())
365 .or_default()
366 .push(source.clone());
367 g.edge_lookup.insert((target.clone(), source.clone()), idx);
368 }
369 }
370 }
371 }
372
373 g.build_bm25_index();
374 Ok(g)
375}
376
377pub fn communities_from_graph(g: &ServeGraph) -> HashMap<i64, Vec<String>> {
381 let mut out: HashMap<i64, Vec<String>> = HashMap::new();
382 for (id, node) in &g.nodes {
383 if let Some(cid) = node.community {
384 out.entry(cid).or_default().push(id.clone());
385 }
386 }
387 out
388}
389
390pub fn strip_diacritics(text: &str) -> String {
394 text.to_lowercase()
397}
398
399pub fn search_tokens(text: &str) -> Vec<String> {
400 let lower = strip_diacritics(text);
401 let re = regex::Regex::new(r"\w+").unwrap();
402 re.find_iter(&lower)
403 .map(|m| m.as_str().to_string())
404 .collect()
405}
406
407pub fn has_chinese(text: &str) -> bool {
408 text.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c))
409}
410
411pub fn segment_chinese(text: &str) -> Vec<String> {
412 let chars: Vec<char> = text.chars().collect();
414 let mut segs: Vec<String> = if chars.len() >= 2 {
415 chars.windows(2).map(|w| w.iter().collect()).collect()
416 } else {
417 vec![text.to_string()]
418 };
419 if chars.len() > 1 && !segs.contains(&text.to_string()) {
421 segs.push(text.to_string());
422 }
423 segs
424}
425
426pub fn is_searchable(term: &str) -> bool {
427 if term.chars().all(|c| c.is_ascii_lowercase()) {
428 term.len() > 2
429 } else {
430 true
431 }
432}
433
434pub fn query_terms(question: &str) -> Vec<String> {
435 let re = regex::Regex::new(r"\w+").unwrap();
436 let mut terms: Vec<String> = Vec::new();
437 for raw in question.split_whitespace() {
438 if has_chinese(raw) {
439 let lower = raw.to_lowercase();
440 let lower = lower.trim();
441 for seg in segment_chinese(lower) {
442 let seg = seg.trim().to_string();
443 if !seg.is_empty() && is_searchable(&seg) {
444 terms.push(seg);
445 }
446 }
447 } else {
448 for tok in re.find_iter(&raw.to_lowercase()) {
449 let s = tok.as_str();
450 if is_searchable(s) {
451 terms.push(s.to_string());
452 }
453 }
454 }
455 }
456 terms
457}
458
459pub fn compute_idf(g: &mut ServeGraph, terms: &[String]) -> HashMap<String, f64> {
463 let n = (g.num_nodes().max(1)) as f64;
464
465 let uncached: Vec<String> = terms
466 .iter()
467 .filter(|t| !g.idf_cache.contains_key(*t))
468 .cloned()
469 .collect();
470
471 if !uncached.is_empty() {
472 let mut df: HashMap<String, usize> = uncached.iter().map(|t| (t.clone(), 0)).collect();
473 for node in g.nodes.values() {
474 let norm = node
475 .norm_label
476 .as_deref()
477 .unwrap_or(&node.label)
478 .to_lowercase();
479 for t in &uncached {
480 if norm.contains(t.as_str()) {
481 *df.get_mut(t).unwrap() += 1;
482 }
483 }
484 }
485 for t in &uncached {
486 let d = df[t] as f64;
487 let idf_val = (1.0 + n / (1.0 + d)).ln();
488 g.idf_cache.insert(t.clone(), idf_val);
489 }
490 }
491
492 let fallback = (1.0 + n).ln();
493 terms
494 .iter()
495 .map(|t| (t.clone(), g.idf_cache.get(t).copied().unwrap_or(fallback)))
496 .collect()
497}
498
499pub fn score_nodes(g: &mut ServeGraph, terms: &[String]) -> Vec<(f64, String)> {
503 let norm_terms: Vec<String> = terms.iter().flat_map(|t| search_tokens(t)).collect();
504 let idf = compute_idf(g, &norm_terms);
505
506 let node_ids: Vec<String> = g.nodes.keys().cloned().collect();
507 let mut scored: Vec<(f64, String)> = Vec::new();
508
509 for nid in &node_ids {
510 let node = &g.nodes[nid];
511 let norm_label = node
512 .norm_label
513 .as_deref()
514 .unwrap_or(&node.label)
515 .to_lowercase();
516 let bare_label = norm_label.trim_end_matches("()").to_string();
517 let source = node.source_file.to_lowercase();
518
519 let mut score = 0.0f64;
520 for t in &norm_terms {
521 let w = idf.get(t).copied().unwrap_or(1.0);
522 if *t == norm_label || *t == bare_label {
523 score += EXACT_MATCH_BONUS * w;
524 } else if norm_label.starts_with(t.as_str()) || bare_label.starts_with(t.as_str()) {
525 score += PREFIX_MATCH_BONUS * w;
526 } else if norm_label.contains(t.as_str()) {
527 score += SUBSTRING_MATCH_BONUS * w;
528 }
529 if source.contains(t.as_str()) {
530 score += SOURCE_MATCH_BONUS * w;
531 }
532 }
533 if score > 0.0 {
534 scored.push((score, nid.clone()));
535 }
536 }
537
538 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
539 scored
540}
541
542pub fn pick_seeds(scored: &[(f64, String)], max_k: usize, gap_ratio: f64) -> Vec<String> {
546 if scored.is_empty() {
547 return Vec::new();
548 }
549 let top_score = scored[0].0;
550 let mut seeds: Vec<String> = Vec::new();
551 for (score, nid) in scored.iter().take(max_k) {
552 if !seeds.is_empty() && *score < top_score * gap_ratio {
553 break;
554 }
555 seeds.push(nid.clone());
556 }
557 seeds
558}
559
560pub fn query_top_nodes(
562 g: &ServeGraph,
563 question: &str,
564 top_k: usize,
565 dense: Option<(&StaticEmbedder, &HashMap<String, Vec<f32>>)>,
566) -> Vec<(String, String, String)> {
567 let terms = query_terms(question);
568 let norm_terms: Vec<String> = terms.iter().flat_map(|t| search_tokens(t)).collect();
569
570 let owned_bm25;
571 let bm25: &Bm25Index = match g.bm25() {
572 Some(b) => b,
573 None => {
574 owned_bm25 = Bm25Index::build(g);
575 &owned_bm25
576 }
577 };
578 let bm25_ranked: Vec<String> = bm25
579 .score(&norm_terms)
580 .into_iter()
581 .map(|(_, id)| id)
582 .collect();
583
584 let symbol_query = is_symbol_query(question);
585
586 let symbol_ranked: Vec<String> = if symbol_query {
587 let split_tokens: Vec<String> = norm_terms
588 .iter()
589 .flat_map(|t| split_camel(t))
590 .filter(|t| !norm_terms.contains(t))
591 .collect();
592 if split_tokens.is_empty() {
593 Vec::new()
594 } else {
595 bm25.score(&split_tokens)
596 .into_iter()
597 .map(|(_, id)| id)
598 .collect()
599 }
600 } else {
601 Vec::new()
602 };
603
604 let merged = if let Some((embedder, node_embs)) = dense {
605 let q_emb = embedder.embed(question);
606 let mut dense_scored: Vec<(f32, String)> = node_embs
607 .iter()
608 .map(|(id, emb)| (cosine_similarity_f32(&q_emb, emb), id.clone()))
609 .collect();
610 dense_scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
611 let dense_ranked: Vec<String> = dense_scored.into_iter().map(|(_, id)| id).collect();
612 if symbol_query && !symbol_ranked.is_empty() {
613 rrf(&[bm25_ranked, dense_ranked, symbol_ranked], RRF_K)
614 } else {
615 rrf(&[bm25_ranked, dense_ranked], RRF_K)
616 }
617 } else {
618 bm25_ranked
619 .iter()
620 .enumerate()
621 .map(|(i, id)| (1.0 / (RRF_K + i + 1) as f64, id.clone()))
622 .collect()
623 };
624
625 let merged = apply_score_adjustments(merged, g, &norm_terms);
626 let seeds = pick_seeds(&merged, top_k.max(3), 0.05);
627
628 seeds
629 .into_iter()
630 .filter_map(|id| {
631 g.get_node(&id)
632 .map(|n| (id.clone(), n.label.clone(), n.source_file.clone()))
633 })
634 .collect()
635}
636
637static CONTEXT_HINTS: &[(&str, &[&str])] = &[
641 (
642 "call",
643 &["call", "calls", "called", "invoke", "invokes", "invoked"],
644 ),
645 (
646 "import",
647 &["import", "imports", "imported", "module", "modules"],
648 ),
649 (
650 "field",
651 &[
652 "field",
653 "fields",
654 "member",
655 "members",
656 "property",
657 "properties",
658 ],
659 ),
660 (
661 "parameter_type",
662 &[
663 "parameter",
664 "parameters",
665 "param",
666 "params",
667 "argument",
668 "arguments",
669 ],
670 ),
671 ("return_type", &["return", "returns", "returned"]),
672 (
673 "generic_arg",
674 &["generic", "generics", "template", "templates"],
675 ),
676];
677
678fn context_filter_aliases() -> HashMap<&'static str, &'static str> {
679 let mut m = HashMap::new();
680 for (canonical, aliases) in &[
681 (
682 "parameter_type",
683 &[
684 "param",
685 "params",
686 "parameter",
687 "parameters",
688 "argument",
689 "arguments",
690 "arg",
691 "args",
692 ][..],
693 ),
694 ("return_type", &["return", "returns", "returned"][..]),
695 (
696 "generic_arg",
697 &["generic", "generics", "template", "templates"][..],
698 ),
699 (
700 "attribute",
701 &["annotation", "annotations", "decorator", "decorators"][..],
702 ),
703 ("call", &["calls", "called", "invoke", "invocation"][..]),
704 (
705 "field",
706 &["fields", "property", "properties", "member", "members"][..],
707 ),
708 ("import", &["imports", "imported", "module", "modules"][..]),
709 ("export", &["exports", "exported"][..]),
710 ] {
711 for alias in *aliases {
712 m.insert(*alias, *canonical);
713 }
714 }
715 m
716}
717
718pub fn normalize_context_filters(filters: &[String]) -> Vec<String> {
719 let aliases = context_filter_aliases();
720 let mut seen: HashSet<String> = HashSet::new();
721 let mut out: Vec<String> = Vec::new();
722 for value in filters {
723 let key = value.trim().to_lowercase();
724 if key.is_empty() {
725 continue;
726 }
727 let key = aliases
728 .get(key.as_str())
729 .map(|s| s.to_string())
730 .unwrap_or(key);
731 if seen.insert(key.clone()) {
732 out.push(key);
733 }
734 }
735 out
736}
737
738pub fn infer_context_filters(question: &str) -> Vec<String> {
739 let re = regex::Regex::new(r"[?,]").unwrap();
740 let lowered: HashSet<String> = re
741 .replace_all(question, " ")
742 .split_whitespace()
743 .map(|t| t.to_lowercase())
744 .collect();
745 let mut inferred: Vec<String> = Vec::new();
746 for (context, hints) in CONTEXT_HINTS {
747 if hints.iter().any(|h| lowered.contains(*h)) {
748 inferred.push(context.to_string());
749 }
750 }
751 inferred
752}
753
754pub fn resolve_context_filters(
755 question: &str,
756 explicit_filters: Option<&[String]>,
757) -> (Vec<String>, Option<String>) {
758 if let Some(f) = explicit_filters {
759 let normalized = normalize_context_filters(f);
760 if !normalized.is_empty() {
761 return (normalized, Some("explicit".to_string()));
762 }
763 }
764 let inferred = infer_context_filters(question);
765 if !inferred.is_empty() {
766 return (inferred, Some("heuristic".to_string()));
767 }
768 (Vec::new(), None)
769}
770
771pub fn filter_graph_by_context(g: &ServeGraph, context_filters: &[String]) -> ServeGraph {
772 let filters: HashSet<String> = normalize_context_filters(context_filters)
773 .into_iter()
774 .collect();
775 if filters.is_empty() {
776 let mut h = g.clone_nodes_only();
778 for e in &g.edges {
779 h.add_edge(
780 &e.source,
781 &e.target,
782 &e.relation,
783 &e.confidence,
784 e.context.as_deref(),
785 );
786 }
787 return h;
788 }
789 let mut h = g.clone_nodes_only();
790 for e in &g.edges {
791 if e.context
792 .as_deref()
793 .map(|c| filters.contains(c))
794 .unwrap_or(false)
795 {
796 h.add_edge(
797 &e.source,
798 &e.target,
799 &e.relation,
800 &e.confidence,
801 e.context.as_deref(),
802 );
803 }
804 }
805 h
806}
807
808fn hub_threshold(g: &ServeGraph) -> usize {
812 let mut degrees: Vec<usize> = g.nodes.keys().map(|n| g.degree(n)).collect();
813 if degrees.is_empty() {
814 return 50;
815 }
816 degrees.sort_unstable();
817 let p99_idx = (degrees.len() as f64 * 0.99) as usize;
818 let p99_idx = p99_idx.min(degrees.len() - 1);
819 degrees[p99_idx].max(50)
820}
821
822pub fn bfs(
823 g: &ServeGraph,
824 start_nodes: &[String],
825 depth: usize,
826) -> (HashSet<String>, Vec<(String, String)>) {
827 let threshold = hub_threshold(g);
828 let seed_set: HashSet<&str> = start_nodes.iter().map(|s| s.as_str()).collect();
829 let mut visited: HashSet<String> = start_nodes.iter().cloned().collect();
830 let mut frontier: HashSet<String> = start_nodes.iter().cloned().collect();
831 let mut edges_seen: Vec<(String, String)> = Vec::new();
832
833 for _ in 0..depth {
834 let mut next_frontier: HashSet<String> = HashSet::new();
835 let frontier_vec: Vec<String> = frontier.iter().cloned().collect();
836 for n in &frontier_vec {
837 if !seed_set.contains(n.as_str()) && g.degree(n) >= threshold {
838 continue;
839 }
840 for neighbor in g.neighbors(n) {
841 if !visited.contains(neighbor) {
842 next_frontier.insert(neighbor.clone());
843 edges_seen.push((n.clone(), neighbor.clone()));
844 }
845 }
846 }
847 for n in &next_frontier {
848 visited.insert(n.clone());
849 }
850 frontier = next_frontier;
851 }
852 (visited, edges_seen)
853}
854
855pub fn dfs(
856 g: &ServeGraph,
857 start_nodes: &[String],
858 depth: usize,
859) -> (HashSet<String>, Vec<(String, String)>) {
860 let threshold = hub_threshold(g);
861 let seed_set: HashSet<&str> = start_nodes.iter().map(|s| s.as_str()).collect();
862 let mut visited: HashSet<String> = HashSet::new();
863 let mut edges_seen: Vec<(String, String)> = Vec::new();
864
865 let mut stack: Vec<(String, usize)> =
867 start_nodes.iter().rev().map(|n| (n.clone(), 0)).collect();
868
869 while let Some((node, d)) = stack.pop() {
870 if visited.contains(&node) || d > depth {
871 continue;
872 }
873 visited.insert(node.clone());
874 if !seed_set.contains(node.as_str()) && g.degree(&node) >= threshold {
875 continue;
876 }
877 for neighbor in g.neighbors(&node) {
878 if !visited.contains(neighbor) {
879 stack.push((neighbor.clone(), d + 1));
880 edges_seen.push((node.clone(), neighbor.clone()));
881 }
882 }
883 }
884 (visited, edges_seen)
885}
886
887pub fn subgraph_to_text(
891 g: &ServeGraph,
892 nodes: &HashSet<String>,
893 edges: &[(String, String)],
894 token_budget: usize,
895 seeds: Option<&[String]>,
896) -> String {
897 let char_budget = token_budget * 3;
898 let mut lines: Vec<String> = Vec::new();
899
900 let seed_set: HashSet<&str> = seeds.unwrap_or(&[]).iter().map(|s| s.as_str()).collect();
901 let mut ordered: Vec<String> = seeds
902 .unwrap_or(&[])
903 .iter()
904 .filter(|n| nodes.contains(*n))
905 .cloned()
906 .collect();
907
908 let mut non_seeds: Vec<&str> = nodes
910 .iter()
911 .filter(|n| !seed_set.contains(n.as_str()))
912 .map(|n| n.as_str())
913 .collect();
914 non_seeds.sort_by_key(|n| std::cmp::Reverse(g.degree(n)));
915 ordered.extend(non_seeds.iter().map(|s| s.to_string()));
916
917 for nid in &ordered {
918 if let Some(node) = g.get_node(nid) {
919 let line = format!(
920 "NODE {} [src={} loc={} community={}]",
921 sanitize_label(Some(&node.label)),
922 sanitize_label(Some(&node.source_file)),
923 sanitize_label(Some(&node.source_location)),
924 sanitize_label(node.community.map(|c| c.to_string()).as_deref()),
925 );
926 lines.push(line);
927 }
928 }
929
930 for (u, v) in edges {
931 if nodes.contains(u) && nodes.contains(v) {
932 if let Some(e) = g.get_edge_data(u, v) {
933 let context_suffix = if let Some(ctx) = &e.context {
934 format!(" context={}", sanitize_label(Some(ctx)))
935 } else {
936 String::new()
937 };
938 let u_label = g
939 .get_node(u)
940 .map(|n| n.label.as_str())
941 .unwrap_or(u.as_str());
942 let v_label = g
943 .get_node(v)
944 .map(|n| n.label.as_str())
945 .unwrap_or(v.as_str());
946 let line = format!(
947 "EDGE {} --{} [{}{}]--> {}",
948 sanitize_label(Some(u_label)),
949 sanitize_label(Some(&e.relation)),
950 sanitize_label(Some(&e.confidence)),
951 context_suffix,
952 sanitize_label(Some(v_label)),
953 );
954 lines.push(line);
955 }
956 }
957 }
958
959 let output = lines.join("\n");
960 if output.len() > char_budget {
961 let cut_at = output[..char_budget].rfind('\n').unwrap_or(char_budget);
962 let cut_at = if cut_at == 0 { char_budget } else { cut_at };
963 let total_nodes: usize = lines.iter().filter(|l| l.starts_with("NODE ")).count();
964 let shown_nodes = output[..cut_at].matches("\nNODE ").count()
965 + if output.starts_with("NODE ") { 1 } else { 0 };
966 let cut_count = total_nodes.saturating_sub(shown_nodes);
967 format!(
968 "{}\n... (truncated — {} more nodes cut by ~{}-token budget.\
969 Narrow with context_filter=['call'] or use get_node for a specific symbol)",
970 &output[..cut_at],
971 cut_count,
972 token_budget,
973 )
974 } else {
975 output
976 }
977}
978
979pub fn find_node(g: &ServeGraph, label: &str) -> Vec<String> {
983 let term = search_tokens(label).join(" ");
984 if term.is_empty() {
985 return Vec::new();
986 }
987 let mut exact: Vec<String> = Vec::new();
988 let mut prefix: Vec<String> = Vec::new();
989 let mut substring: Vec<String> = Vec::new();
990
991 for (nid, node) in &g.nodes {
992 let norm_label = node
993 .norm_label
994 .as_deref()
995 .unwrap_or(&node.label)
996 .to_lowercase();
997 let bare_label = norm_label.trim_end_matches("()").to_string();
998 let nid_lower = nid.to_lowercase();
999
1000 if term == norm_label || term == bare_label || term == nid_lower {
1001 exact.push(nid.clone());
1002 } else if norm_label.starts_with(&term)
1003 || bare_label.starts_with(&term)
1004 || nid_lower.starts_with(&term)
1005 {
1006 prefix.push(nid.clone());
1007 } else if norm_label.contains(&term) {
1008 substring.push(nid.clone());
1009 }
1010 }
1011
1012 exact.extend(prefix);
1013 exact.extend(substring);
1014 exact
1015}
1016
1017fn traverse_render(
1021 g: &ServeGraph,
1022 seeds: Vec<String>,
1023 question: &str,
1024 mode: &str,
1025 depth: usize,
1026 token_budget: usize,
1027 context_filters: Option<&[String]>,
1028) -> String {
1029 let (resolved_filters, filter_source) = resolve_context_filters(question, context_filters);
1030 let traversal_graph = filter_graph_by_context(g, &resolved_filters);
1031
1032 let (nodes, edges) = if mode == "dfs" {
1033 dfs(&traversal_graph, &seeds, depth)
1034 } else {
1035 bfs(&traversal_graph, &seeds, depth)
1036 };
1037
1038 let start_labels: Vec<String> = seeds
1039 .iter()
1040 .map(|n| {
1041 g.get_node(n)
1042 .map(|nd| nd.label.clone())
1043 .unwrap_or_else(|| n.clone())
1044 })
1045 .collect();
1046
1047 let mut header_parts = vec![
1048 format!("Traversal: {} depth={}", mode.to_uppercase(), depth),
1049 format!("Start: {:?}", start_labels),
1050 ];
1051 if !resolved_filters.is_empty() {
1052 header_parts.push(format!(
1053 "Context: {} ({})",
1054 resolved_filters.join(", "),
1055 filter_source.as_deref().unwrap_or(""),
1056 ));
1057 }
1058 header_parts.push(format!("{} nodes found", nodes.len()));
1059
1060 let header = header_parts.join(" | ") + "\n\n";
1061 header + &subgraph_to_text(&traversal_graph, &nodes, &edges, token_budget, Some(&seeds))
1062}
1063
1064pub fn query_graph_text(
1065 g: &mut ServeGraph,
1066 question: &str,
1067 mode: &str,
1068 depth: usize,
1069 token_budget: usize,
1070 context_filters: Option<&[String]>,
1071) -> String {
1072 let terms = query_terms(question);
1073 let scored = score_nodes(g, &terms);
1074 let seeds = pick_seeds(&scored, 3, 0.2);
1075 if seeds.is_empty() {
1076 return "No matching nodes found.".to_string();
1077 }
1078 traverse_render(
1079 g,
1080 seeds,
1081 question,
1082 mode,
1083 depth,
1084 token_budget,
1085 context_filters,
1086 )
1087}
1088
1089fn parse_byte_range(s: &str) -> Option<(usize, usize)> {
1093 let (a, b) = s.split_once(':')?;
1094 Some((a.parse().ok()?, b.parse().ok()?))
1095}
1096
1097fn read_node_body_exact(source_file: &str, source_location: &str) -> String {
1098 let Some((start, end)) = parse_byte_range(source_location) else {
1099 return String::new();
1100 };
1101 let Ok(content) = std::fs::read(source_file) else {
1102 return String::new();
1103 };
1104 let end = end.min(content.len());
1105 if start >= end {
1106 return String::new();
1107 }
1108 String::from_utf8_lossy(&content[start..end]).into_owned()
1109}
1110
1111const BM25_K1: f64 = 1.2;
1112const BM25_B: f64 = 0.75;
1113pub const RRF_K: usize = 60;
1114
1115pub struct Bm25Index {
1116 term_freqs: HashMap<String, HashMap<String, usize>>,
1117 doc_freqs: HashMap<String, usize>,
1118 avg_dl: f64,
1119 num_docs: usize,
1120}
1121
1122impl Bm25Index {
1123 pub fn build(g: &ServeGraph) -> Self {
1124 let mut term_freqs: HashMap<String, HashMap<String, usize>> = HashMap::new();
1125 let mut doc_freqs: HashMap<String, usize> = HashMap::new();
1126 let mut total_len = 0usize;
1127 let num_docs = g.nodes.len();
1128
1129 let mut child_labels: HashMap<String, Vec<String>> = HashMap::new();
1131 for edge in g.edges_iter() {
1132 if edge.relation == "method" || edge.relation == "contains" {
1133 if let Some(target_node) = g.get_node(&edge.target) {
1134 child_labels
1135 .entry(edge.source.clone())
1136 .or_default()
1137 .push(target_node.label.clone());
1138 }
1139 }
1140 }
1141
1142 for (id, node) in g.nodes_iter() {
1143 let docstring_str = node.docstring.as_deref().unwrap_or("");
1144 let methods = child_labels
1145 .get(id)
1146 .map(|v| v.join(" "))
1147 .unwrap_or_default();
1148 let body_str = if !node.source_location.is_empty() {
1149 read_node_body_exact(&node.source_file, &node.source_location)
1150 } else {
1151 String::new()
1152 };
1153 let text = format!(
1154 "{} {} {} {} {}",
1155 node.norm_label.as_deref().unwrap_or(&node.label),
1156 docstring_str,
1157 node.source_file,
1158 methods,
1159 body_str
1160 );
1161 let text = text.trim().to_string();
1162 let tokens = search_tokens(&text);
1163 total_len += tokens.len();
1164
1165 let mut tf: HashMap<String, usize> = HashMap::new();
1166 for tok in &tokens {
1167 *tf.entry(tok.clone()).or_insert(0) += 1;
1168 }
1169 for tok in tf.keys() {
1170 *doc_freqs.entry(tok.clone()).or_insert(0) += 1;
1171 }
1172 term_freqs.insert(id.to_string(), tf);
1173 }
1174
1175 let avg_dl = if num_docs > 0 {
1176 total_len as f64 / num_docs as f64
1177 } else {
1178 1.0
1179 };
1180
1181 Bm25Index {
1182 term_freqs,
1183 doc_freqs,
1184 avg_dl,
1185 num_docs,
1186 }
1187 }
1188
1189 pub fn score(&self, query_terms: &[String]) -> Vec<(f64, String)> {
1190 if self.num_docs == 0 || query_terms.is_empty() {
1191 return Vec::new();
1192 }
1193 let n = self.num_docs as f64;
1194 let idfs: Vec<(&String, f64)> = query_terms
1195 .iter()
1196 .map(|t| {
1197 let df = self.doc_freqs.get(t).copied().unwrap_or(0) as f64;
1198 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln().max(0.0);
1199 (t, idf)
1200 })
1201 .collect();
1202
1203 let mut scored: Vec<(f64, String)> = self
1204 .term_freqs
1205 .iter()
1206 .filter_map(|(id, tf_map)| {
1207 let dl = tf_map.values().sum::<usize>() as f64;
1208 let denom_base = BM25_K1 * (1.0 - BM25_B + BM25_B * dl / self.avg_dl);
1209 let mut s = 0.0f64;
1210 for (t, idf) in &idfs {
1211 let tf = tf_map.get(*t).copied().unwrap_or(0) as f64;
1212 if tf > 0.0 {
1213 s += idf * tf * (BM25_K1 + 1.0) / (tf + denom_base);
1214 }
1215 }
1216 if s > 0.0 {
1217 Some((s, id.clone()))
1218 } else {
1219 None
1220 }
1221 })
1222 .collect();
1223
1224 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
1225 scored
1226 }
1227}
1228
1229pub fn rrf(ranked_lists: &[Vec<String>], k: usize) -> Vec<(f64, String)> {
1231 let mut scores: HashMap<String, f64> = HashMap::new();
1232 for list in ranked_lists {
1233 for (rank, id) in list.iter().enumerate() {
1234 *scores.entry(id.clone()).or_insert(0.0) += 1.0 / (k + rank + 1) as f64;
1235 }
1236 }
1237 let mut result: Vec<(f64, String)> = scores.into_iter().map(|(id, s)| (s, id)).collect();
1238 result.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
1239 result
1240}
1241
1242fn is_test_or_example_file(path: &str) -> bool {
1244 let p = path.to_lowercase();
1245 p.contains("/test/")
1246 || p.contains("/tests/")
1247 || p.contains("/examples/")
1248 || p.contains("/example/")
1249 || p.contains("/spec/")
1250 || p.contains("/fixtures/")
1251 || p.contains("/fixture/")
1252 || p.ends_with("_test.py")
1253 || p.ends_with("_test.rs")
1254 || p.ends_with("_test.go")
1255 || p.ends_with("_test.js")
1256 || p.ends_with("_test.ts")
1257 || p.ends_with(".spec.ts")
1258 || p.ends_with(".spec.js")
1259 || p.ends_with(".test.ts")
1260 || p.ends_with(".test.js")
1261 || (p.ends_with(".java") && (p.contains("test") || p.contains("Test")))
1262}
1263
1264pub fn split_camel(token: &str) -> Vec<String> {
1267 if token.contains('_') {
1268 return vec![token.to_lowercase()];
1269 }
1270 let mut parts: Vec<String> = Vec::new();
1271 let mut current = String::new();
1272 let chars: Vec<char> = token.chars().collect();
1273 for (i, &c) in chars.iter().enumerate() {
1274 if c.is_uppercase() && i > 0 {
1275 let next_is_lower = chars
1276 .get(i + 1)
1277 .map(|nc| nc.is_lowercase())
1278 .unwrap_or(false);
1279 let prev_is_lower = chars[i - 1].is_lowercase();
1280 if (prev_is_lower || next_is_lower) && !current.is_empty() {
1281 parts.push(current.to_lowercase());
1282 current = String::new();
1283 }
1284 }
1285 current.push(c);
1286 }
1287 if !current.is_empty() {
1288 parts.push(current.to_lowercase());
1289 }
1290 if parts.len() <= 1 {
1291 return vec![token.to_lowercase()];
1292 }
1293 parts
1294}
1295
1296fn is_symbol_query(question: &str) -> bool {
1298 let tokens: Vec<&str> = question.split_whitespace().collect();
1299 if tokens.len() != 1 {
1300 return false;
1301 }
1302 let t = tokens[0];
1303 t.chars().all(|c| c.is_alphanumeric() || c == '_')
1305 && (t.chars().any(|c| c.is_uppercase()) || t.contains('_'))
1306}
1307
1308fn apply_score_adjustments(
1310 mut merged: Vec<(f64, String)>,
1311 g: &ServeGraph,
1312 norm_terms: &[String],
1313) -> Vec<(f64, String)> {
1314 let mut file_counts: HashMap<String, usize> = HashMap::new();
1316
1317 for (score, id) in &mut merged {
1318 let node = match g.get_node(id) {
1319 Some(n) => n,
1320 None => continue,
1321 };
1322
1323 if is_test_or_example_file(&node.source_file) {
1325 *score *= 0.05;
1326 }
1327
1328 let label_lower = node.label.to_lowercase();
1330 let mut boosted = false;
1331 for term in norm_terms {
1332 if label_lower == *term {
1333 *score *= 3.0;
1334 boosted = true;
1335 break;
1336 }
1337 }
1338 if !boosted {
1339 for term in norm_terms {
1340 if label_lower.contains(term.as_str()) {
1341 *score *= 1.5;
1342 break;
1343 }
1344 }
1345 }
1346
1347 let n = file_counts.entry(node.source_file.clone()).or_insert(0);
1349 if *n > 0 {
1350 *score *= 0.5f64.powi(*n as i32);
1351 }
1352 *n += 1;
1353 }
1354
1355 merged.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
1356 merged
1357}
1358
1359pub fn query_graph_text_hybrid(
1364 g: &ServeGraph,
1365 question: &str,
1366 mode: &str,
1367 depth: usize,
1368 token_budget: usize,
1369 context_filters: Option<&[String]>,
1370 dense: Option<(&StaticEmbedder, &HashMap<String, Vec<f32>>)>,
1371) -> String {
1372 let terms = query_terms(question);
1373 let norm_terms: Vec<String> = terms.iter().flat_map(|t| search_tokens(t)).collect();
1374
1375 let owned_bm25;
1376 let bm25: &Bm25Index = match g.bm25() {
1377 Some(b) => b,
1378 None => {
1379 owned_bm25 = Bm25Index::build(g);
1380 &owned_bm25
1381 }
1382 };
1383 let bm25_ranked: Vec<String> = bm25
1384 .score(&norm_terms)
1385 .into_iter()
1386 .map(|(_, id)| id)
1387 .collect();
1388
1389 let symbol_query = is_symbol_query(question);
1391
1392 let merged = if let Some((embedder, node_embs)) = dense {
1393 let q_emb = embedder.embed(question);
1394 let mut dense_scored: Vec<(f32, String)> = node_embs
1395 .iter()
1396 .map(|(id, emb)| (cosine_similarity_f32(&q_emb, emb), id.clone()))
1397 .collect();
1398 dense_scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
1399 let dense_ranked: Vec<String> = dense_scored.into_iter().map(|(_, id)| id).collect();
1400 if symbol_query {
1401 rrf(&[bm25_ranked.clone(), bm25_ranked, dense_ranked], RRF_K)
1403 } else {
1404 rrf(&[bm25_ranked, dense_ranked], RRF_K)
1405 }
1406 } else {
1407 bm25_ranked
1408 .iter()
1409 .enumerate()
1410 .map(|(i, id)| (1.0 / (RRF_K + i + 1) as f64, id.clone()))
1411 .collect()
1412 };
1413
1414 let merged = apply_score_adjustments(merged, g, &norm_terms);
1415
1416 let seeds = pick_seeds(&merged, 3, 0.2);
1417 if seeds.is_empty() {
1418 return "No matching nodes found.".to_string();
1419 }
1420 traverse_render(
1421 g,
1422 seeds,
1423 question,
1424 mode,
1425 depth,
1426 token_budget,
1427 context_filters,
1428 )
1429}
1430
1431pub fn resolve_query(
1437 query: &str,
1438 graph_path: &Path,
1439 top_k: usize,
1440 max_chars: usize,
1441) -> Result<String, String> {
1442 let g = load_graph(graph_path).map_err(|e| e.to_string())?;
1443 let candidates = query_top_nodes(&g, query, top_k.max(10), None);
1446 if candidates.is_empty() {
1447 return Ok("No matching nodes found.".into());
1448 }
1449
1450 let mut ranked = candidates;
1452 ranked.sort_by_key(|(id, _, _)| {
1453 g.get_node(id)
1454 .map(|n| if n.source_location.is_empty() { 1 } else { 0 })
1455 .unwrap_or(1)
1456 });
1457 let top_nodes: Vec<_> = ranked.into_iter().take(top_k).collect();
1458
1459 let query_lower = query.to_lowercase();
1460 let query_tokens: Vec<&str> = query_lower.split_whitespace().collect();
1461
1462 let mut file_to_nodes: std::collections::HashMap<&str, Vec<&str>> =
1464 std::collections::HashMap::new();
1465 for (id, node) in g.nodes_iter() {
1466 if !node.source_location.is_empty() && !node.source_file.is_empty() {
1467 file_to_nodes
1468 .entry(node.source_file.as_str())
1469 .or_default()
1470 .push(id);
1471 }
1472 }
1473
1474 let mut sections: Vec<String> = Vec::new();
1475 let mut total_chars = 0usize;
1476
1477 for (node_id, label, source_file) in &top_nodes {
1478 if total_chars >= max_chars {
1479 break;
1480 }
1481 let caller_count = g
1482 .edges_iter()
1483 .filter(|e| e.target == *node_id && e.relation.contains("call"))
1484 .count();
1485 let caller_note = if caller_count == 0 {
1486 " [0 explicit callers — may be entry point, registered callback, or unused]".to_string()
1487 } else {
1488 format!(" [{} caller(s)]", caller_count)
1489 };
1490
1491 let mut sec = format!("═══ {} ({}){}\n", label, source_file, caller_note);
1492
1493 let mut primary_body_shown = false;
1494 if let Some(node) = g.get_node(node_id) {
1495 if !node.source_location.is_empty() {
1496 let body = read_node_body_exact(&node.source_file, &node.source_location);
1497 if !body.is_empty() {
1498 let cap = body.len().min(4000);
1499 sec.push_str(&body[..cap]);
1500 sec.push('\n');
1501 primary_body_shown = true;
1502 }
1503 }
1504
1505 if !primary_body_shown && !node.source_file.is_empty() {
1508 if let Some(sibling_ids) = file_to_nodes.get(node.source_file.as_str()) {
1509 let mut scored: Vec<(usize, &&str)> = sibling_ids
1510 .iter()
1511 .filter_map(|sid| {
1512 g.get_node(sid).map(|sn| {
1513 let nl = sn.label.to_lowercase();
1514 let score = query_tokens.iter().filter(|t| nl.contains(*t)).count();
1515 (score, sid)
1516 })
1517 })
1518 .collect();
1519 scored.sort_by_key(|a| std::cmp::Reverse(a.0));
1520
1521 for (_, sid) in scored.iter().take(2) {
1522 if let Some(sn) = g.get_node(sid) {
1523 let body = read_node_body_exact(&sn.source_file, &sn.source_location);
1524 if !body.is_empty() {
1525 let cap = body.len().min(3000);
1526 sec.push_str(&format!("── {}\n", sn.label));
1527 sec.push_str(&body[..cap]);
1528 sec.push('\n');
1529 }
1530 }
1531 }
1532 }
1533 }
1534 }
1535
1536 total_chars += sec.len();
1537 sections.push(sec);
1538 }
1539
1540 Ok(sections.join("\n"))
1541}
1542
1543#[cfg(test)]
1547mod tests {
1548 use super::*;
1549 use std::io::Write;
1550 use tempfile::NamedTempFile;
1551
1552 fn make_graph() -> ServeGraph {
1553 let mut g = ServeGraph::new_undirected();
1554 g.add_node("n1", "extract", "extract.py", "L10", Some(0));
1555 g.add_node("n2", "cluster", "cluster.py", "L5", Some(0));
1556 g.add_node("n3", "build", "build.py", "L1", Some(1));
1557 g.add_node("n4", "report", "report.py", "L1", Some(1));
1558 g.add_node("n5", "isolated", "other.py", "L1", Some(2));
1559 g.add_edge("n1", "n2", "calls", "INFERRED", Some("call"));
1560 g.add_edge("n2", "n3", "imports", "EXTRACTED", Some("import"));
1561 g.add_edge("n3", "n4", "uses", "EXTRACTED", None);
1562 g
1563 }
1564
1565 fn make_noisy_graph() -> ServeGraph {
1566 let mut g = ServeGraph::new_undirected();
1567 for i in 0..20usize {
1568 let id = format!("err{}", i);
1569 let label = format!("error_handler_{}", i);
1570 g.add_node(&id, &label, &format!("err{}.py", i), "", Some(0));
1571 if i > 0 {
1572 let prev = format!("err{}", i - 1);
1573 g.add_edge(&prev, &id, "calls", "EXTRACTED", None);
1574 }
1575 }
1576 g.add_node("fbs", "FooBarService", "service.py", "", Some(1));
1577 g.add_node("fbs_dep", "ServiceClient", "client.py", "", Some(1));
1578 g.add_edge("fbs", "fbs_dep", "uses", "EXTRACTED", None);
1579 g
1580 }
1581
1582 fn write_graph_json(nodes: &[(&str, &str)]) -> (NamedTempFile, String) {
1583 let mut tmp = NamedTempFile::with_suffix(".json").unwrap();
1584 let nodes_json: Vec<String> = nodes
1585 .iter()
1586 .map(|(id, label)| {
1587 format!(
1588 r#"{{"id":"{id}","label":"{label}","community":0}}"#,
1589 id = id,
1590 label = label
1591 )
1592 })
1593 .collect();
1594 let content = format!(
1595 r#"{{"directed":false,"multigraph":false,"graph":{{}},"nodes":[{}],"links":[]}}"#,
1596 nodes_json.join(",")
1597 );
1598 tmp.write_all(content.as_bytes()).unwrap();
1599 let path = tmp.path().to_str().unwrap().to_string();
1600 (tmp, path)
1601 }
1602
1603 #[test]
1606 fn test_communities_from_graph_basic() {
1607 let g = make_graph();
1608 let communities = communities_from_graph(&g);
1609 assert!(communities.contains_key(&0));
1610 assert!(communities.contains_key(&1));
1611 assert!(communities[&0].contains(&"n1".to_string()));
1612 assert!(communities[&0].contains(&"n2".to_string()));
1613 assert!(communities[&1].contains(&"n3".to_string()));
1614 }
1615
1616 #[test]
1617 fn test_communities_from_graph_no_community_attr() {
1618 let mut g = ServeGraph::new_undirected();
1619 g.add_node("a", "foo", "", "", None);
1620 let communities = communities_from_graph(&g);
1621 assert!(communities.is_empty());
1622 }
1623
1624 #[test]
1625 fn test_communities_from_graph_isolated() {
1626 let g = make_graph();
1627 let communities = communities_from_graph(&g);
1628 assert!(communities.contains_key(&2));
1629 assert!(communities[&2].contains(&"n5".to_string()));
1630 }
1631
1632 #[test]
1635 fn test_score_nodes_exact_label_match() {
1636 let mut g = make_graph();
1637 let scored = score_nodes(&mut g, &["extract".to_string()]);
1638 let nids: Vec<&str> = scored.iter().map(|(_, id)| id.as_str()).collect();
1639 assert!(nids.contains(&"n1"));
1640 assert_eq!(scored[0].1, "n1");
1641 }
1642
1643 #[test]
1644 fn test_score_nodes_no_match() {
1645 let mut g = make_graph();
1646 let scored = score_nodes(&mut g, &["xyzzy".to_string()]);
1647 assert!(scored.is_empty());
1648 }
1649
1650 #[test]
1651 fn test_score_nodes_source_file_partial() {
1652 let mut g = make_graph();
1653 let scored = score_nodes(&mut g, &["cluster".to_string()]);
1654 let nids: Vec<&str> = scored.iter().map(|(_, id)| id.as_str()).collect();
1655 assert!(nids.contains(&"n2"));
1656 }
1657
1658 #[test]
1659 fn test_score_nodes_ignores_trailing_punctuation() {
1660 let mut g = make_graph();
1661 let scored = score_nodes(&mut g, &["extract?".to_string()]);
1662 assert!(!scored.is_empty());
1663 assert_eq!(scored[0].1, "n1");
1664 }
1665
1666 #[test]
1669 fn test_find_node_ignores_trailing_punctuation() {
1670 let g = make_graph();
1671 let matches = find_node(&g, "extract?");
1672 assert!(matches.contains(&"n1".to_string()));
1673 }
1674
1675 #[test]
1678 fn test_query_terms_strips_search_punctuation() {
1679 let terms = query_terms("what calls extract?");
1680 assert!(terms.contains(&"what".to_string()));
1681 assert!(terms.contains(&"calls".to_string()));
1682 assert!(terms.contains(&"extract".to_string()));
1683 assert!(!terms.contains(&"extract?".to_string()));
1684 }
1685
1686 #[test]
1687 fn test_query_terms_filters_short_english_terms() {
1688 let terms = query_terms("to of dependency install");
1689 assert!(!terms.contains(&"to".to_string()));
1691 assert!(!terms.contains(&"of".to_string()));
1692 assert!(terms.contains(&"dependency".to_string()));
1693 assert!(terms.contains(&"install".to_string()));
1694 }
1695
1696 #[test]
1697 fn test_query_terms_non_chinese_scripts_not_segmented() {
1698 let text = "かなカナ한글";
1700 assert!(!has_chinese(text));
1701 let terms = query_terms(text);
1702 assert!(terms.contains(&"かなカナ한글".to_string()));
1703 }
1704
1705 #[test]
1706 fn test_query_terms_chinese_bigram_fallback() {
1707 let terms = query_terms("页面路由");
1709 assert!(terms.contains(&"页面".to_string()));
1711 assert!(terms.contains(&"路由".to_string()));
1712 assert!(terms.contains(&"页面路由".to_string()));
1713 assert_eq!(terms.len(), 4);
1714 }
1715
1716 #[test]
1717 fn test_query_terms_chinese_keeps_original_term() {
1718 let terms = query_terms("路由配置");
1720 assert!(
1721 terms.contains(&"路由配置".to_string()),
1722 "original term must be preserved"
1723 );
1724 }
1725
1726 #[test]
1727 fn test_query_terms_chinese_mixed() {
1728 let terms = query_terms("前端 router 路由配置");
1729 assert!(terms.contains(&"前端".to_string()));
1730 assert!(terms.contains(&"router".to_string()));
1731 assert!(terms
1733 .iter()
1734 .any(|t| t.contains("路由") || t.contains("配置")));
1735 }
1736
1737 #[test]
1740 fn test_query_graph_text_keeps_short_non_english_terms() {
1741 let mut g = ServeGraph::new_undirected();
1742 g.add_node("frontend", "前端", "docs/前端.md", "L1", Some(0));
1743 let text = query_graph_text(&mut g, "前端", "bfs", 1, 2000, None);
1744 assert!(!text.contains("No matching nodes found."));
1745 assert!(text.contains("NODE 前端"));
1746 }
1747
1748 #[test]
1751 fn test_infer_context_filters_for_calls_question() {
1752 let filters = infer_context_filters("who calls extract");
1753 assert!(filters.contains(&"call".to_string()));
1754 }
1755
1756 #[test]
1757 fn test_resolve_context_filters_explicit_overrides_heuristic() {
1758 let explicit = vec!["field".to_string()];
1759 let (filters, source) = resolve_context_filters("who calls extract", Some(&explicit));
1760 assert_eq!(filters, vec!["field".to_string()]);
1761 assert_eq!(source.as_deref(), Some("explicit"));
1762 }
1763
1764 #[test]
1767 fn test_bfs_depth_1() {
1768 let g = make_graph();
1769 let (visited, _edges) = bfs(&g, &["n1".to_string()], 1);
1770 assert!(visited.contains("n1"));
1771 assert!(visited.contains("n2"));
1772 assert!(!visited.contains("n3")); }
1774
1775 #[test]
1776 fn test_bfs_depth_2() {
1777 let g = make_graph();
1778 let (visited, _edges) = bfs(&g, &["n1".to_string()], 2);
1779 assert!(visited.contains("n3")); }
1781
1782 #[test]
1783 fn test_bfs_disconnected() {
1784 let g = make_graph();
1785 let (visited, _edges) = bfs(&g, &["n5".to_string()], 3);
1786 assert_eq!(visited, ["n5".to_string()].iter().cloned().collect());
1787 }
1788
1789 #[test]
1790 fn test_bfs_returns_edges() {
1791 let g = make_graph();
1792 let (visited, edges) = bfs(&g, &["n1".to_string()], 1);
1793 assert!(visited.contains("n1"));
1794 assert!(!edges.is_empty());
1795 assert!(edges.iter().any(|(u, v)| u == "n1" || v == "n1"));
1796 }
1797
1798 #[test]
1801 fn test_filter_graph_by_context_limits_traversal() {
1802 let g = make_graph();
1803 let filtered = filter_graph_by_context(&g, &["call".to_string()]);
1804 let (visited, edges) = bfs(&filtered, &["n1".to_string()], 2);
1805 assert!(visited.contains("n2"));
1806 assert!(!visited.contains("n3")); assert_eq!(edges.len(), 1);
1808 assert!(edges
1809 .iter()
1810 .any(|(u, v)| (u == "n1" && v == "n2") || (u == "n2" && v == "n1")));
1811 }
1812
1813 #[test]
1816 fn test_dfs_depth_1() {
1817 let g = make_graph();
1818 let (visited, _) = dfs(&g, &["n1".to_string()], 1);
1819 assert!(visited.contains("n1"));
1820 assert!(visited.contains("n2"));
1821 assert!(!visited.contains("n3"));
1822 }
1823
1824 #[test]
1825 fn test_dfs_full_chain() {
1826 let g = make_graph();
1827 let (visited, _) = dfs(&g, &["n1".to_string()], 5);
1828 assert!(visited.is_superset(
1829 &["n1", "n2", "n3", "n4"]
1830 .iter()
1831 .map(|s| s.to_string())
1832 .collect()
1833 ));
1834 }
1835
1836 #[test]
1839 fn test_subgraph_to_text_contains_labels() {
1840 let g = make_graph();
1841 let nodes: HashSet<String> = ["n1", "n2"].iter().map(|s| s.to_string()).collect();
1842 let edges = vec![("n1".to_string(), "n2".to_string())];
1843 let text = subgraph_to_text(&g, &nodes, &edges, 2000, None);
1844 assert!(text.contains("extract"));
1845 assert!(text.contains("cluster"));
1846 }
1847
1848 #[test]
1849 fn test_subgraph_to_text_truncates() {
1850 let g = make_graph();
1851 let nodes: HashSet<String> = ["n1", "n2", "n3", "n4"]
1852 .iter()
1853 .map(|s| s.to_string())
1854 .collect();
1855 let edges = vec![("n1".to_string(), "n2".to_string())];
1856 let text = subgraph_to_text(&g, &nodes, &edges, 1, None);
1857 assert!(text.contains("truncated"));
1858 }
1859
1860 #[test]
1861 fn test_subgraph_to_text_edge_included() {
1862 let g = make_graph();
1863 let nodes: HashSet<String> = ["n1", "n2"].iter().map(|s| s.to_string()).collect();
1864 let edges = vec![("n1".to_string(), "n2".to_string())];
1865 let text = subgraph_to_text(&g, &nodes, &edges, 2000, None);
1866 assert!(text.contains("EDGE"));
1867 assert!(text.contains("calls"));
1868 }
1869
1870 #[test]
1871 fn test_subgraph_to_text_includes_edge_context() {
1872 let g = make_graph();
1873 let nodes: HashSet<String> = ["n1", "n2"].iter().map(|s| s.to_string()).collect();
1874 let edges = vec![("n1".to_string(), "n2".to_string())];
1875 let text = subgraph_to_text(&g, &nodes, &edges, 2000, None);
1876 assert!(text.contains("context=call"));
1877 }
1878
1879 #[test]
1882 fn test_query_graph_text_explicit_context_filter_changes_traversal() {
1883 let mut g = make_graph();
1884 let filters = vec!["call".to_string()];
1885 let text = query_graph_text(&mut g, "extract", "bfs", 2, 2000, Some(&filters));
1886 assert!(text.contains("Context: call (explicit)"));
1887 assert!(text.contains("cluster"));
1888 assert!(!text.contains("build"));
1889 }
1890
1891 #[test]
1892 fn test_query_graph_text_heuristic_context_filter_changes_traversal() {
1893 let mut g = make_graph();
1894 let text = query_graph_text(&mut g, "who calls extract", "bfs", 2, 2000, None);
1895 assert!(text.contains("Context: call (heuristic)"));
1896 assert!(text.contains("cluster"));
1897 assert!(!text.contains("build"));
1898 }
1899
1900 #[test]
1903 fn test_load_graph_roundtrip() {
1904 let (tmp, _path) =
1905 write_graph_json(&[("n1", "extract"), ("n2", "cluster"), ("n3", "build")]);
1906 let g = load_graph(tmp.path()).unwrap();
1907 assert_eq!(g.num_nodes(), 3);
1908 assert_eq!(g.num_edges(), 0);
1909 }
1910
1911 #[test]
1912 fn test_load_graph_missing_file() {
1913 let result = load_graph(Path::new("/tmp/nonexistent_codesynapse_test.json"));
1914 assert!(matches!(result, Err(LoadError::Io(_))));
1915 }
1916
1917 #[test]
1918 fn test_load_graph_rejects_oversized_file() {
1919 let (tmp, _path) = write_graph_json(&[("n1", "extract")]);
1920 let result = load_graph_with_cap(tmp.path(), 16);
1921 assert!(matches!(result, Err(LoadError::TooLarge { .. })));
1922 if let Err(e) = result {
1923 let msg = e.to_string();
1924 assert!(msg.contains("exceeds"), "msg: {}", msg);
1925 assert!(msg.contains("byte cap"), "msg: {}", msg);
1926 }
1927 }
1928
1929 #[test]
1930 fn test_load_graph_accepts_under_cap() {
1931 let (tmp, _path) = write_graph_json(&[("n1", "extract")]);
1932 let result = load_graph_with_cap(tmp.path(), 10 * 1024 * 1024);
1933 assert!(result.is_ok());
1934 let g = result.unwrap();
1935 assert_eq!(g.num_nodes(), 1);
1936 }
1937
1938 #[test]
1941 fn test_load_graph_detects_graph_change() {
1942 let dir = tempfile::tempdir().unwrap();
1943 let path = dir.path().join("graph.json");
1944
1945 std::fs::write(&path, r#"{"directed":false,"multigraph":false,"graph":{},"nodes":[{"id":"alpha","label":"alpha","community":0},{"id":"beta","label":"beta","community":0}],"links":[]}"#).unwrap();
1946 let g1 = load_graph(&path).unwrap();
1947 assert!(g1.contains_node("alpha"));
1948 assert!(g1.contains_node("beta"));
1949
1950 std::thread::sleep(std::time::Duration::from_millis(10));
1951 std::fs::write(&path, r#"{"directed":false,"multigraph":false,"graph":{},"nodes":[{"id":"alpha","label":"alpha","community":0},{"id":"beta","label":"beta","community":0},{"id":"gamma","label":"gamma","community":0}],"links":[]}"#).unwrap();
1952 let g2 = load_graph(&path).unwrap();
1953 assert!(g2.contains_node("gamma"));
1954 }
1955
1956 #[test]
1957 fn test_load_graph_cache_key_changes_with_content() {
1958 let dir = tempfile::tempdir().unwrap();
1959 let path = dir.path().join("graph.json");
1960
1961 std::fs::write(&path, r#"{"directed":false,"multigraph":false,"graph":{},"nodes":[{"id":"a","label":"a","community":0}],"links":[]}"#).unwrap();
1962 let s1 = std::fs::metadata(&path).unwrap();
1963 let key1 = (s1.modified().unwrap(), s1.len());
1964
1965 std::thread::sleep(std::time::Duration::from_millis(10));
1966 std::fs::write(&path, r#"{"directed":false,"multigraph":false,"graph":{},"nodes":[{"id":"a","label":"a","community":0},{"id":"b","label":"b","community":0}],"links":[]}"#).unwrap();
1967 let s2 = std::fs::metadata(&path).unwrap();
1968 let key2 = (s2.modified().unwrap(), s2.len());
1969
1970 assert_ne!(key1, key2, "stat key must change when file content changes");
1971 }
1972
1973 #[test]
1976 fn test_idf_downweights_common_terms() {
1977 let mut g = make_noisy_graph();
1978 let scored = score_nodes(&mut g, &["foobarservice".to_string(), "error".to_string()]);
1979 assert!(!scored.is_empty());
1980 assert_eq!(scored[0].1, "fbs");
1981 }
1982
1983 #[test]
1984 fn test_idf_cached_on_graph() {
1985 let mut g = make_graph();
1986 score_nodes(&mut g, &["extract".to_string()]);
1987 assert!(g.idf_cache.contains_key("extract"));
1988 }
1989
1990 #[test]
1991 fn test_idf_new_graph_starts_fresh() {
1992 let mut g1 = make_graph();
1993 let g2 = make_graph();
1994 score_nodes(&mut g1, &["extract".to_string()]);
1995 assert!(!g2.idf_cache.contains_key("extract"));
1996 }
1997
1998 #[test]
1999 fn test_idf_rare_term_gets_high_weight() {
2000 let mut g = make_graph(); let idf = compute_idf(&mut g, &["extract".to_string()]);
2002 assert!(idf["extract"] > 1.0);
2004 }
2005
2006 #[test]
2007 fn test_idf_common_term_gets_low_weight() {
2008 let mut g = ServeGraph::new_undirected();
2009 for i in 0..20usize {
2010 g.add_node(
2011 &format!("n{}", i),
2012 &format!("handle_{}", i),
2013 &format!("f{}.py", i),
2014 "",
2015 None,
2016 );
2017 }
2018 let idf = compute_idf(&mut g, &["handle".to_string()]);
2019 assert!(idf["handle"] < 1.0);
2020 }
2021
2022 #[test]
2025 fn test_pick_seeds_dominant_identifier_gives_one_seed() {
2026 let scored = vec![
2027 (1000.0, "fbs".to_string()),
2028 (1.0, "err1".to_string()),
2029 (0.9, "err2".to_string()),
2030 ];
2031 let seeds = pick_seeds(&scored, 3, 0.2);
2032 assert_eq!(seeds, vec!["fbs".to_string()]);
2033 }
2034
2035 #[test]
2036 fn test_pick_seeds_close_scores_keeps_multiple() {
2037 let scored = vec![
2038 (10.0, "a".to_string()),
2039 (9.0, "b".to_string()),
2040 (8.5, "c".to_string()),
2041 ];
2042 let seeds = pick_seeds(&scored, 3, 0.2);
2043 assert_eq!(seeds.len(), 3);
2044 }
2045
2046 #[test]
2047 fn test_pick_seeds_empty() {
2048 let seeds = pick_seeds(&[], 3, 0.2);
2049 assert!(seeds.is_empty());
2050 }
2051
2052 #[test]
2053 fn test_pick_seeds_single() {
2054 let scored = vec![(5.0, "x".to_string())];
2055 let seeds = pick_seeds(&scored, 3, 0.2);
2056 assert_eq!(seeds, vec!["x".to_string()]);
2057 }
2058
2059 #[test]
2060 fn test_pick_seeds_respects_max_k() {
2061 let scored: Vec<(f64, String)> = (0..10).map(|i| (10.0, format!("n{}", i))).collect();
2062 let seeds = pick_seeds(&scored, 3, 0.2);
2063 assert_eq!(seeds.len(), 3);
2064 }
2065
2066 #[test]
2069 fn test_subgraph_to_text_truncation_hint_is_actionable() {
2070 let g = make_graph();
2071 let nodes: HashSet<String> = ["n1", "n2", "n3", "n4"]
2072 .iter()
2073 .map(|s| s.to_string())
2074 .collect();
2075 let edges = vec![("n1".to_string(), "n2".to_string())];
2076 let text = subgraph_to_text(&g, &nodes, &edges, 1, None);
2077 assert!(text.contains("truncated"));
2078 assert!(text.contains("get_node") || text.contains("context_filter"));
2079 }
2080
2081 #[test]
2084 fn test_query_seeds_from_identifier_not_noise() {
2085 let mut g = make_noisy_graph();
2086 let text = query_graph_text(&mut g, "FooBarService error handling", "bfs", 2, 2000, None);
2087 assert!(text.contains("FooBarService"));
2088 assert!(text.contains("ServiceClient"));
2089 }
2090
2091 #[test]
2094 fn test_query_graph_text_parameter_type_context_filter_changes_traversal() {
2095 let mut g = ServeGraph::new_undirected();
2096 g.add_node("process", "process", "sample.cs", "L20", None);
2097 g.add_node("payload", "Payload", "sample.cs", "L5", None);
2098 g.add_node("other", "PayloadFactory", "sample.cs", "L40", None);
2099 g.add_edge(
2100 "process",
2101 "payload",
2102 "references",
2103 "EXTRACTED",
2104 Some("parameter_type"),
2105 );
2106 g.add_edge("process", "other", "calls", "EXTRACTED", Some("call"));
2107
2108 let filters = vec!["parameter_type".to_string()];
2109 let text = query_graph_text(
2110 &mut g,
2111 "who accepts Payload",
2112 "bfs",
2113 2,
2114 2000,
2115 Some(&filters),
2116 );
2117 assert!(text.contains("parameter_type"));
2118 assert!(text.contains("Payload"));
2119 assert!(!text.contains("PayloadFactory"));
2120 }
2121
2122 #[test]
2125 fn test_context_filter_aliases_resolve() {
2126 assert_eq!(
2127 normalize_context_filters(&["param".to_string()]),
2128 vec!["parameter_type"]
2129 );
2130 assert_eq!(
2131 normalize_context_filters(&["parameter".to_string()]),
2132 vec!["parameter_type"]
2133 );
2134 assert_eq!(
2135 normalize_context_filters(&["return".to_string()]),
2136 vec!["return_type"]
2137 );
2138 assert_eq!(
2139 normalize_context_filters(&["returns".to_string()]),
2140 vec!["return_type"]
2141 );
2142 assert_eq!(
2143 normalize_context_filters(&["generic".to_string()]),
2144 vec!["generic_arg"]
2145 );
2146 assert_eq!(
2147 normalize_context_filters(&["generics".to_string()]),
2148 vec!["generic_arg"]
2149 );
2150 assert_eq!(
2151 normalize_context_filters(&["annotation".to_string()]),
2152 vec!["attribute"]
2153 );
2154 assert_eq!(
2155 normalize_context_filters(&["decorator".to_string()]),
2156 vec!["attribute"]
2157 );
2158 assert_eq!(
2160 normalize_context_filters(&["parameter_type".to_string()]),
2161 vec!["parameter_type"]
2162 );
2163 assert_eq!(
2164 normalize_context_filters(&["field".to_string()]),
2165 vec!["field"]
2166 );
2167 }
2168
2169 #[test]
2172 fn test_score_nodes_chinese_substring_match() {
2173 let mut g = ServeGraph::new_undirected();
2174 g.add_node("n1", "路由桥接核对表", "doc.md", "", Some(0));
2175 g.add_node("n2", "其他内容", "doc.md", "", Some(0));
2176 let scored = score_nodes(&mut g, &["路由".to_string()]);
2177 let nids: Vec<&str> = scored.iter().map(|(_, id)| id.as_str()).collect();
2178 assert!(nids.contains(&"n1"));
2179 assert!(!nids.contains(&"n2"));
2180 }
2181
2182 #[test]
2185 fn test_query_text_chinese_finds_routing_nodes() {
2186 let mut g = ServeGraph::new_undirected();
2187 g.add_node("parent", "页面路由规范", "doc.md", "L1", Some(0));
2188 g.add_node("child", "路由桥接核对表", "doc.md", "L10", Some(0));
2189 g.add_edge("parent", "child", "contains", "EXTRACTED", None);
2190 let text = query_graph_text(&mut g, "页面路由", "bfs", 2, 2000, None);
2191 assert!(!text.contains("No matching nodes found."));
2192 assert!(text.contains("路由"));
2193 }
2194
2195 #[test]
2198 fn test_bm25_empty_graph() {
2199 let g = ServeGraph::new_undirected();
2200 let idx = Bm25Index::build(&g);
2201 assert!(idx.score(&["foo".to_string()]).is_empty());
2202 }
2203
2204 #[test]
2205 fn test_bm25_score_hit() {
2206 let mut g = ServeGraph::new_undirected();
2207 g.add_node("n1", "extract_nodes", "extract.py", "", None);
2208 g.add_node("n2", "build_graph", "build.py", "", None);
2209 let idx = Bm25Index::build(&g);
2210 let scored = idx.score(&["extract".to_string()]);
2211 assert!(!scored.is_empty());
2212 assert_eq!(scored[0].1, "n1");
2213 }
2214
2215 #[test]
2216 fn test_bm25_score_miss() {
2217 let mut g = ServeGraph::new_undirected();
2218 g.add_node("n1", "foo_bar", "foo.py", "", None);
2219 let idx = Bm25Index::build(&g);
2220 let scored = idx.score(&["xyzzy".to_string()]);
2221 assert!(scored.is_empty());
2222 }
2223
2224 #[test]
2225 fn test_bm25_score_ranking_specificity() {
2226 let mut g = ServeGraph::new_undirected();
2227 g.add_node("n1", "extract_pipeline", "extract.py", "", None);
2229 g.add_node("n2", "build_graph", "build.py", "", None);
2230 let idx = Bm25Index::build(&g);
2231 let scored = idx.score(&["extract".to_string()]);
2232 let ids: Vec<&str> = scored.iter().map(|(_, id)| id.as_str()).collect();
2233 assert!(ids.contains(&"n1"));
2234 assert!(!ids.contains(&"n2"));
2235 }
2236
2237 #[test]
2238 fn test_bm25_no_terms() {
2239 let mut g = ServeGraph::new_undirected();
2240 g.add_node("n1", "foo", "foo.py", "", None);
2241 let idx = Bm25Index::build(&g);
2242 assert!(idx.score(&[]).is_empty());
2243 }
2244
2245 #[test]
2248 fn test_rrf_single_list() {
2249 let list = vec!["a".to_string(), "b".to_string(), "c".to_string()];
2250 let scored = rrf(&[list], RRF_K);
2251 assert_eq!(scored.len(), 3);
2252 assert!(scored[0].0 > scored[1].0);
2254 assert!(scored[1].0 > scored[2].0);
2255 assert_eq!(scored[0].1, "a");
2256 }
2257
2258 #[test]
2259 fn test_rrf_fusion_boosts_overlap() {
2260 let list1 = vec!["a".to_string(), "b".to_string(), "c".to_string()];
2261 let list2 = vec!["b".to_string(), "a".to_string(), "d".to_string()];
2262 let scored = rrf(&[list1, list2], RRF_K);
2263 let top: Vec<&str> = scored.iter().take(2).map(|(_, id)| id.as_str()).collect();
2264 assert!(top.contains(&"a") || top.contains(&"b"));
2266 let all_ids: Vec<&str> = scored.iter().map(|(_, id)| id.as_str()).collect();
2268 assert!(all_ids.contains(&"d"));
2269 assert!(all_ids.contains(&"c"));
2270 }
2271
2272 #[test]
2273 fn test_rrf_empty() {
2274 let result = rrf(&[], RRF_K);
2275 assert!(result.is_empty());
2276 }
2277
2278 #[test]
2281 fn test_hybrid_query_no_dense_finds_nodes() {
2282 let g = make_graph();
2283 let text = query_graph_text_hybrid(&g, "extract", "bfs", 2, 2000, None, None);
2284 assert!(!text.contains("No matching nodes found."));
2285 assert!(text.contains("extract"));
2286 }
2287
2288 #[test]
2289 fn test_hybrid_query_no_match_returns_not_found() {
2290 let g = make_graph();
2291 let text = query_graph_text_hybrid(&g, "zzznonexistent", "bfs", 2, 2000, None, None);
2292 assert!(text.contains("No matching nodes found."));
2293 }
2294
2295 #[test]
2298 fn test_parse_byte_range_valid() {
2299 assert_eq!(parse_byte_range("0:100"), Some((0, 100)));
2300 assert_eq!(parse_byte_range("42:84"), Some((42, 84)));
2301 }
2302
2303 #[test]
2304 fn test_parse_byte_range_invalid() {
2305 assert_eq!(parse_byte_range(""), None);
2306 assert_eq!(parse_byte_range("abc:def"), None);
2307 assert_eq!(parse_byte_range("100"), None);
2308 assert_eq!(parse_byte_range(":50"), None);
2309 }
2310
2311 #[test]
2314 fn test_read_node_body_exact_returns_correct_slice() {
2315 let mut tmp = NamedTempFile::new().unwrap();
2316 tmp.write_all(b"hello world foo bar").unwrap();
2317 let path = tmp.path().to_str().unwrap();
2318 assert_eq!(read_node_body_exact(path, "6:11"), "world");
2319 }
2320
2321 #[test]
2322 fn test_read_node_body_exact_invalid_location() {
2323 assert_eq!(
2324 read_node_body_exact("nonexistent_codesynapse.py", "0:10"),
2325 ""
2326 );
2327 assert_eq!(
2328 read_node_body_exact("nonexistent_codesynapse.py", "bad"),
2329 ""
2330 );
2331 }
2332
2333 #[test]
2336 fn test_bm25_indexes_source_location_body() {
2337 let mut tmp = NamedTempFile::new().unwrap();
2338 let body = b"def handle_not_found():\n raise NotFound\n";
2339 tmp.write_all(body).unwrap();
2340 let path = tmp.path().to_str().unwrap().to_string();
2341
2342 let mut g = ServeGraph::new_undirected();
2343 g.add_node("n1", "handle_not_found", &path, "", None);
2344 if let Some(n) = g.nodes.get_mut("n1") {
2345 n.source_location = format!("0:{}", body.len());
2346 }
2347 g.add_node("n2", "other_func", "other.py", "", None);
2348
2349 let idx = Bm25Index::build(&g);
2350 let scored = idx.score(&["notfound".to_string()]);
2351 let ids: Vec<&str> = scored.iter().map(|(_, id)| id.as_str()).collect();
2352 assert!(
2353 ids.contains(&"n1"),
2354 "n1 should rank for 'notfound' body token"
2355 );
2356 assert!(!ids.contains(&"n2"), "n2 should not rank for 'notfound'");
2357 }
2358
2359 #[test]
2362 fn test_bm25_docstring_tokens_indexed() {
2363 let mut g = ServeGraph::new_undirected();
2364 g.add_node("n1", "PaymentService", "payment.py", "", None);
2365 if let Some(n) = g.nodes.get_mut("n1") {
2367 n.docstring = Some("payment processing gateway".into());
2368 }
2369 g.add_node("n2", "UserService", "user.py", "", None);
2370 let idx = Bm25Index::build(&g);
2371 let scored = idx.score(&["payment".to_string()]);
2372 let ids: Vec<&str> = scored.iter().map(|(_, id)| id.as_str()).collect();
2373 assert!(
2374 ids.contains(&"n1"),
2375 "n1 should rank due to docstring token 'payment'"
2376 );
2377 assert!(!ids.contains(&"n2"), "n2 should not rank for 'payment'");
2378 }
2379
2380 #[test]
2381 fn test_bm25_docstring_gateway_token() {
2382 let mut g = ServeGraph::new_undirected();
2383 g.add_node("n1", "PaymentService", "payment.py", "", None);
2384 if let Some(n) = g.nodes.get_mut("n1") {
2385 n.docstring = Some("payment processing gateway".into());
2386 }
2387 let idx = Bm25Index::build(&g);
2388 let scored = idx.score(&["gateway".to_string()]);
2389 let ids: Vec<&str> = scored.iter().map(|(_, id)| id.as_str()).collect();
2390 assert!(ids.contains(&"n1"), "n1 should rank for 'gateway'");
2391 }
2392
2393 #[test]
2396 fn test_split_camel_pascal_case() {
2397 assert_eq!(split_camel("QueryHandler"), vec!["query", "handler"]);
2398 }
2399
2400 #[test]
2401 fn test_split_camel_camel_case() {
2402 assert_eq!(split_camel("queryHandler"), vec!["query", "handler"]);
2403 }
2404
2405 #[test]
2406 fn test_split_camel_snake_case_unchanged() {
2407 assert_eq!(split_camel("query_handler"), vec!["query_handler"]);
2408 }
2409
2410 #[test]
2411 fn test_split_camel_all_caps_no_split() {
2412 assert_eq!(split_camel("HTTP"), vec!["http"]);
2413 }
2414
2415 #[test]
2416 fn test_split_camel_single_word() {
2417 assert_eq!(split_camel("handler"), vec!["handler"]);
2418 }
2419
2420 #[test]
2421 fn test_split_camel_multiple_caps_run() {
2422 assert_eq!(split_camel("HTTPRequest"), vec!["http", "request"]);
2423 }
2424
2425 #[test]
2426 fn test_split_camel_three_words() {
2427 assert_eq!(split_camel("FooBarBaz"), vec!["foo", "bar", "baz"]);
2428 }
2429
2430 #[test]
2433 fn test_symbol_ranked_from_split_tokens() {
2434 let mut g = ServeGraph::new_undirected();
2435 g.add_node("n1", "QueryHandler", "handler.py", "", None);
2436 g.add_node("n2", "UserService", "service.py", "", None);
2437 let idx = Bm25Index::build(&g);
2438 let split = split_camel("QueryHandler");
2439 let scored = idx.score(&split);
2440 let ids: Vec<&str> = scored.iter().map(|(_, id)| id.as_str()).collect();
2441 assert!(
2442 ids.contains(&"n1"),
2443 "n1 should rank for split tokens of QueryHandler"
2444 );
2445 }
2446
2447 #[test]
2450 fn test_bm25_index_built_after_load_graph() {
2451 let tmp = tempfile::tempdir().unwrap();
2452 let json = serde_json::json!({
2453 "nodes": [{"id": "n1", "label": "foo_bar", "file_type": "code", "source_file": ""}],
2454 "edges": []
2455 });
2456 let path = tmp.path().join("g.json");
2457 std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
2458 let g = load_graph(&path).unwrap();
2459 assert!(
2460 g.bm25_index.is_some(),
2461 "BM25 index must be built at load time"
2462 );
2463 }
2464
2465 #[test]
2466 fn test_query_top_nodes_accepts_shared_ref() {
2467 let mut g = ServeGraph::new_undirected();
2468 g.add_node("n1", "extract_nodes", "extract.py", "", None);
2469 g.add_node("n2", "build_graph", "build.py", "", None);
2470 g.build_bm25_index();
2471 let results = query_top_nodes(&g, "extract", 3, None);
2472 assert!(!results.is_empty());
2473 assert_eq!(results[0].0, "n1");
2474 }
2475
2476 #[test]
2477 fn test_bm25_index_reused_across_calls() {
2478 let mut g = ServeGraph::new_undirected();
2479 g.add_node("n1", "authenticate_user", "auth.rs", "", None);
2480 g.build_bm25_index();
2481 let r1 = query_top_nodes(&g, "authenticate", 3, None);
2482 let r2 = query_top_nodes(&g, "authenticate", 3, None);
2483 assert_eq!(
2484 r1, r2,
2485 "results must be identical across calls (same cached index)"
2486 );
2487 }
2488
2489 #[test]
2490 fn test_query_graph_text_hybrid_shared_ref() {
2491 let mut g = ServeGraph::new_undirected();
2492 g.add_node("n1", "extract_pipeline", "extract.py", "", None);
2493 g.add_node("n2", "build_graph", "build.py", "", None);
2494 g.add_edge("n1", "n2", "calls", "EXTRACTED", None);
2495 g.build_bm25_index();
2496 let text = query_graph_text_hybrid(&g, "extract", "bfs", 2, 2000, None, None);
2497 assert!(!text.contains("No matching nodes found."));
2498 }
2499
2500 #[test]
2501 fn test_resolve_query_returns_top_nodes() {
2502 let json = r#"{"directed":false,"nodes":[{"id":"n1","label":"extract_pipeline","source_file":"extract.py"},{"id":"n2","label":"cluster_nodes","source_file":"cluster.py"}],"edges":[]}"#;
2503 let dir = tempfile::tempdir().unwrap();
2504 let path = dir.path().join("graph.json");
2505 std::fs::write(&path, json).unwrap();
2506 let result = resolve_query("extract", &path, 3, 10000).unwrap();
2507 assert!(
2508 result.contains("extract_pipeline"),
2509 "expected top node in output: {result}"
2510 );
2511 }
2512
2513 #[test]
2514 fn test_resolve_query_missing_file_returns_err() {
2515 use std::path::PathBuf;
2516 let path = PathBuf::from("/nonexistent/graph.json");
2517 assert!(resolve_query("anything", &path, 3, 10000).is_err());
2518 }
2519}