Skip to main content

codesynapse_serve/
graph_query.rs

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        // Ensure both endpoints exist in adj
106        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        // Forward
121        self.adj
122            .entry(source.to_string())
123            .or_default()
124            .push(target.to_string());
125
126        if !self.directed {
127            // Backward (undirected)
128            self.adj
129                .entry(target.to_string())
130                .or_default()
131                .push(source.to_string());
132            // Also store reverse lookup so get_edge_data works both ways
133            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            // Each edge stored once internally but appears in both directions
155            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// ---------------------------------------------------------------------------
214// Load graph from networkx node-link JSON
215
216#[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    // Support both "links" and "edges" key
317    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
377// ---------------------------------------------------------------------------
378// Graph stat helpers
379
380pub 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
390// ---------------------------------------------------------------------------
391// Text / query helpers
392
393pub fn strip_diacritics(text: &str) -> String {
394    // Simplified: just lowercase for ASCII; full NFKD would need unicode-normalization crate.
395    // Sufficient for all tests in this module.
396    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    // Bigram fallback (no jieba in Rust)
413    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    // Append original term if it has >1 char and isn't already in segs
420    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
459// ---------------------------------------------------------------------------
460// IDF
461
462pub 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
499// ---------------------------------------------------------------------------
500// Scoring
501
502pub 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
542// ---------------------------------------------------------------------------
543// Seed selection
544
545pub 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
560/// Returns (node_id, label, source_file) for the top-K seeds from hybrid search.
561pub 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
637// ---------------------------------------------------------------------------
638// Context filters
639
640static 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        // Return a clone
777        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
808// ---------------------------------------------------------------------------
809// BFS / DFS
810
811fn 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    // Stack: (node, current_depth)
866    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
887// ---------------------------------------------------------------------------
888// Subgraph rendering
889
890pub 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    // Non-seed nodes sorted by degree descending
909    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
979// ---------------------------------------------------------------------------
980// find_node
981
982pub 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
1017// ---------------------------------------------------------------------------
1018// Main query entry point
1019
1020fn 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
1089// ---------------------------------------------------------------------------
1090// BM25 index
1091
1092fn 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        // Build child method labels map for corpus enrichment
1130        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
1229/// Reciprocal Rank Fusion over multiple ranked lists. `k=60` is standard.
1230pub 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
1242/// Returns true for test, spec, fixture, or example files.
1243fn 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
1264/// Split camelCase/PascalCase into lowercase tokens. Snake_case returned as-is (already meaningful in BM25).
1265/// Original token is NOT included — caller already has it in bm25_ranked.
1266pub 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
1296/// Symbol query: single token that looks like an identifier (PascalCase, snake_case, ALL_CAPS).
1297fn 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    // All caps, camelCase, PascalCase, or snake_case with no spaces
1304    t.chars().all(|c| c.is_alphanumeric() || c == '_')
1305        && (t.chars().any(|c| c.is_uppercase()) || t.contains('_'))
1306}
1307
1308/// Post-RRF score adjustments ported from synapse's `_hybrid_search`.
1309fn apply_score_adjustments(
1310    mut merged: Vec<(f64, String)>,
1311    g: &ServeGraph,
1312    norm_terms: &[String],
1313) -> Vec<(f64, String)> {
1314    // Apply per-node adjustments in rank order so saturation decay is stable.
1315    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        // Test/example file penalty
1324        if is_test_or_example_file(&node.source_file) {
1325            *score *= 0.05;
1326        }
1327
1328        // Label exact/substring match boost
1329        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        // Saturation decay: nth node from same file → score × 0.5^n
1348        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
1359/// Hybrid query: BM25 lexical + optional dense cosine, fused with RRF.
1360///
1361/// `dense` = `Some((embedder, node_embeddings))` when a model is loaded.
1362/// Without dense, falls back to BM25-only ranking (still better than pure IDF).
1363pub 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    // Symbol queries (single identifier) are BM25-heavy: add BM25 list twice in RRF.
1390    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            // BM25 weight 2:1 over dense for symbol queries
1402            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
1431// ---------------------------------------------------------------------------
1432// CLI-facing query helpers
1433
1434/// Load global graph from `graph_path`, run BM25 query, return formatted
1435/// source bodies for the top-k matching nodes. No dense embeddings (CLI).
1436pub 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    // Fetch a wider candidate pool so re-ranking can surface nodes with source bodies
1444    // that may be ranked below the immediate top-k by BM25 alone.
1445    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    // Stable-sort: nodes with source_location first, preserving BM25 order within each group.
1451    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    // Build a source_file → nodes-with-loc index for the same-file siblings fallback.
1463    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            // Fallback: find sibling nodes in the same file that have source_location,
1506            // score by query token overlap, show the best match.
1507            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// ---------------------------------------------------------------------------
1544// Tests
1545
1546#[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    // --- communities_from_graph ---
1604
1605    #[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    // --- score_nodes ---
1633
1634    #[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    // --- find_node ---
1667
1668    #[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    // --- query_terms ---
1676
1677    #[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        // "to" (2 chars) and "of" (2 chars) dropped; long terms kept
1690        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        // Japanese kana and Hangul should not be treated as Chinese
1699        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        // Rust uses bigram fallback (no jieba): "页面路由" → bigrams + original
1708        let terms = query_terms("页面路由");
1709        // bigrams: 页面, 面路, 路由, original: 页面路由
1710        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        // Original multi-char Chinese term is always appended to bigrams for exact-match support
1719        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        // 路由配置 → bigrams include 路由, 由配, 配置
1732        assert!(terms
1733            .iter()
1734            .any(|t| t.contains("路由") || t.contains("配置")));
1735    }
1736
1737    // --- query_graph_text (non-english) ---
1738
1739    #[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    // --- context filters ---
1749
1750    #[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    // --- BFS ---
1765
1766    #[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")); // 2 hops away
1773    }
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")); // n1 -> n2 -> n3
1780    }
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    // --- filter_graph_by_context ---
1799
1800    #[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")); // n2-n3 has context=import, filtered out
1807        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    // --- DFS ---
1814
1815    #[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    // --- subgraph_to_text ---
1837
1838    #[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    // --- query_graph_text with context filters ---
1880
1881    #[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    // --- load_graph ---
1901
1902    #[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    // --- hot reload ---
1939
1940    #[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    // --- IDF ---
1974
1975    #[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(); // 5 nodes
2001        let idf = compute_idf(&mut g, &["extract".to_string()]);
2002        // extract matches only n1: IDF = log(1 + 5/2) ≈ 1.25
2003        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    // --- pick_seeds ---
2023
2024    #[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    // --- truncation hint ---
2067
2068    #[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    // --- integration ---
2082
2083    #[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    // --- parameter_type context ---
2092
2093    #[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    // --- normalize_context_filters aliases ---
2123
2124    #[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        // Pass-through canonical values
2159        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    // --- Chinese scoring ---
2170
2171    #[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    // --- Chinese query pipeline ---
2183
2184    #[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    // --- Bm25Index ---
2196
2197    #[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        // n1 has "extract" twice (in label and filename), n2 once
2228        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    // --- rrf ---
2246
2247    #[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        // scores must be strictly decreasing
2253        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        // "a" and "b" appear in both lists → should be top-2
2265        assert!(top.contains(&"a") || top.contains(&"b"));
2266        // "d" and "c" are in only one list → lower rank
2267        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    // --- query_graph_text_hybrid ---
2279
2280    #[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    // --- parse_byte_range ---
2296
2297    #[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    // --- read_node_body_exact ---
2312
2313    #[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    // --- BM25 source_location body indexing ---
2334
2335    #[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    // --- BM25 docstring indexing ---
2360
2361    #[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        // Manually set docstring on the node
2366        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    // --- split_camel ---
2394
2395    #[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    // --- is_symbol_query + symbol_ranked integration ---
2431
2432    #[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    // --- Issue 2: BM25 cache on ServeGraph ---
2448
2449    #[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}