Skip to main content

codesynapse_core/
serve_query.rs

1use crate::security::{sanitize_label, MAX_GRAPH_FILE_BYTES};
2use std::collections::{HashMap, HashSet};
3
4// ---- Data structures ----
5
6#[derive(Clone, Debug)]
7pub struct NodeData {
8    pub label: String,
9    pub source_file: String,
10    pub source_location: String,
11    pub community: Option<i64>,
12}
13
14#[derive(Clone, Debug)]
15pub struct EdgeData {
16    pub relation: String,
17    pub confidence: String,
18    pub context: Option<String>,
19}
20
21#[derive(Debug)]
22pub struct QueryGraph {
23    pub nodes: HashMap<String, NodeData>,
24    adj: HashMap<String, Vec<(String, EdgeData)>>,
25    pub idf_cache: HashMap<String, f64>,
26}
27
28impl Default for QueryGraph {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl QueryGraph {
35    pub fn new() -> Self {
36        QueryGraph {
37            nodes: HashMap::new(),
38            adj: HashMap::new(),
39            idf_cache: HashMap::new(),
40        }
41    }
42
43    pub fn add_node(&mut self, id: impl Into<String>, data: NodeData) {
44        let id = id.into();
45        self.adj.entry(id.clone()).or_default();
46        self.nodes.insert(id, data);
47    }
48
49    pub fn add_edge(&mut self, u: impl Into<String>, v: impl Into<String>, data: EdgeData) {
50        let u: String = u.into();
51        let v: String = v.into();
52        let reverse = EdgeData {
53            relation: data.relation.clone(),
54            confidence: data.confidence.clone(),
55            context: data.context.clone(),
56        };
57        self.adj
58            .entry(u.clone())
59            .or_default()
60            .push((v.clone(), data));
61        self.adj.entry(v.clone()).or_default().push((u, reverse));
62        self.nodes.entry(v).or_insert_with(|| NodeData {
63            label: String::new(),
64            source_file: String::new(),
65            source_location: String::new(),
66            community: None,
67        });
68    }
69
70    pub fn neighbors(&self, id: &str) -> Vec<(&str, &EdgeData)> {
71        self.adj
72            .get(id)
73            .map(|v| v.iter().map(|(n, e)| (n.as_str(), e)).collect())
74            .unwrap_or_default()
75    }
76
77    pub fn degree(&self, id: &str) -> usize {
78        self.adj.get(id).map(|v| v.len()).unwrap_or(0)
79    }
80
81    pub fn node_count(&self) -> usize {
82        self.nodes.len()
83    }
84
85    fn all_degrees(&self) -> Vec<usize> {
86        self.nodes.keys().map(|id| self.degree(id)).collect()
87    }
88
89    fn hub_threshold(&self) -> usize {
90        let mut degrees = self.all_degrees();
91        if degrees.is_empty() {
92            return 50;
93        }
94        degrees.sort_unstable();
95        let p99_idx = (degrees.len() as f64 * 0.99) as usize;
96        let p99_idx = p99_idx.min(degrees.len() - 1);
97        degrees[p99_idx].max(50)
98    }
99}
100
101// ---- Text processing ----
102
103pub fn has_chinese(text: &str) -> bool {
104    text.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c))
105}
106
107fn search_tokens(text: &str) -> Vec<String> {
108    let lower = text.to_lowercase();
109    let mut tokens = Vec::new();
110    let mut current = String::new();
111    for c in lower.chars() {
112        if c.is_alphanumeric() || c == '_' {
113            current.push(c);
114        } else if !current.is_empty() {
115            tokens.push(current.clone());
116            current.clear();
117        }
118    }
119    if !current.is_empty() {
120        tokens.push(current);
121    }
122    tokens
123}
124
125fn is_searchable(term: &str) -> bool {
126    let all_ascii_alpha = term.chars().all(|c| c.is_ascii_lowercase());
127    if all_ascii_alpha {
128        term.len() > 2
129    } else {
130        true
131    }
132}
133
134fn segment_chinese_bigrams(text: &str) -> Vec<String> {
135    let chars: Vec<char> = text.chars().collect();
136    let mut segments: Vec<String> = chars
137        .windows(2)
138        .map(|w| w.iter().collect::<String>())
139        .collect();
140    if segments.is_empty() && !chars.is_empty() {
141        segments.push(text.to_string());
142    }
143    if text.len() > 1 && !segments.contains(&text.to_string()) {
144        segments.push(text.to_string());
145    }
146    segments
147}
148
149pub fn query_terms(question: &str) -> Vec<String> {
150    let mut terms: Vec<String> = Vec::new();
151    for raw in question.split_whitespace() {
152        if has_chinese(raw) {
153            let lower = raw.to_lowercase();
154            let lower = lower.trim();
155            for seg in segment_chinese_bigrams(lower) {
156                let seg = seg.trim().to_string();
157                if !seg.is_empty() && is_searchable(&seg) {
158                    terms.push(seg);
159                }
160            }
161        } else {
162            for tok in search_tokens(raw) {
163                if is_searchable(&tok) {
164                    terms.push(tok);
165                }
166            }
167        }
168    }
169    terms
170}
171
172// ---- IDF ----
173
174const EXACT_MATCH_BONUS: f64 = 1000.0;
175const PREFIX_MATCH_BONUS: f64 = 100.0;
176const SUBSTRING_MATCH_BONUS: f64 = 1.0;
177const SOURCE_MATCH_BONUS: f64 = 0.5;
178
179pub fn compute_idf(g: &mut QueryGraph, terms: &[String]) -> HashMap<String, f64> {
180    let n = g.node_count().max(1) as f64;
181    let uncached: Vec<String> = terms
182        .iter()
183        .filter(|t| !g.idf_cache.contains_key(*t))
184        .cloned()
185        .collect();
186
187    if !uncached.is_empty() {
188        let mut df: HashMap<String, usize> = uncached.iter().map(|t| (t.clone(), 0)).collect();
189        for data in g.nodes.values() {
190            let norm_label = data.label.to_lowercase();
191            for t in &uncached {
192                if norm_label.contains(t.as_str()) {
193                    *df.get_mut(t).unwrap() += 1;
194                }
195            }
196        }
197        for t in &uncached {
198            let d = *df.get(t).unwrap_or(&0) as f64;
199            g.idf_cache.insert(t.clone(), (1.0 + n / (1.0 + d)).ln());
200        }
201    }
202
203    terms
204        .iter()
205        .map(|t| {
206            let w = g
207                .idf_cache
208                .get(t)
209                .copied()
210                .unwrap_or_else(|| (1.0 + n).ln());
211            (t.clone(), w)
212        })
213        .collect()
214}
215
216pub fn score_nodes(g: &mut QueryGraph, terms: &[String]) -> Vec<(f64, String)> {
217    let norm_terms: Vec<String> = terms.iter().flat_map(|t| search_tokens(t)).collect();
218    if norm_terms.is_empty() {
219        return vec![];
220    }
221    let idf = compute_idf(g, &norm_terms);
222
223    let node_ids: Vec<String> = g.nodes.keys().cloned().collect();
224    let mut scored: Vec<(f64, String)> = Vec::new();
225    for nid in node_ids {
226        let data = g.nodes.get(&nid).unwrap();
227        let norm_label = data.label.to_lowercase();
228        let bare_label = norm_label.trim_end_matches(['(', ')']).to_string();
229        let source = data.source_file.to_lowercase();
230        let mut score = 0.0f64;
231        for t in &norm_terms {
232            let w = idf.get(t).copied().unwrap_or(1.0);
233            if *t == norm_label || *t == bare_label {
234                score += EXACT_MATCH_BONUS * w;
235            } else if norm_label.starts_with(t.as_str()) || bare_label.starts_with(t.as_str()) {
236                score += PREFIX_MATCH_BONUS * w;
237            } else if norm_label.contains(t.as_str()) {
238                score += SUBSTRING_MATCH_BONUS * w;
239            }
240            if source.contains(t.as_str()) {
241                score += SOURCE_MATCH_BONUS * w;
242            }
243        }
244        if score > 0.0 {
245            scored.push((score, nid));
246        }
247    }
248    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
249    scored
250}
251
252pub fn pick_seeds(scored: &[(f64, String)], max_k: usize, gap_ratio: f64) -> Vec<String> {
253    if scored.is_empty() {
254        return vec![];
255    }
256    let top_score = scored[0].0;
257    let mut seeds = Vec::new();
258    for (score, nid) in scored.iter().take(max_k) {
259        if !seeds.is_empty() && *score < top_score * gap_ratio {
260            break;
261        }
262        seeds.push(nid.clone());
263    }
264    seeds
265}
266
267// ---- Context filters ----
268
269static CONTEXT_HINTS: &[(&str, &[&str])] = &[
270    (
271        "call",
272        &["call", "calls", "called", "invoke", "invokes", "invoked"],
273    ),
274    (
275        "import",
276        &["import", "imports", "imported", "module", "modules"],
277    ),
278    (
279        "field",
280        &[
281            "field",
282            "fields",
283            "member",
284            "members",
285            "property",
286            "properties",
287        ],
288    ),
289    (
290        "parameter_type",
291        &[
292            "parameter",
293            "parameters",
294            "param",
295            "params",
296            "argument",
297            "arguments",
298        ],
299    ),
300    ("return_type", &["return", "returns", "returned"]),
301    (
302        "generic_arg",
303        &["generic", "generics", "template", "templates"],
304    ),
305];
306
307fn context_alias(key: &str) -> &str {
308    match key {
309        "param" | "params" | "parameter" | "parameters" | "argument" | "arguments" | "arg"
310        | "args" => "parameter_type",
311        "return" | "returns" | "returned" => "return_type",
312        "generic" | "generics" | "template" | "templates" => "generic_arg",
313        "annotation" | "annotations" | "decorator" | "decorators" => "attribute",
314        "calls" | "called" | "invoke" | "invocation" => "call",
315        "fields" | "property" | "properties" | "member" | "members" => "field",
316        "imports" | "imported" | "module" | "modules" => "import",
317        "exports" | "exported" => "export",
318        other => other,
319    }
320}
321
322pub fn normalize_context_filters(filters: &[String]) -> Vec<String> {
323    let mut seen = HashSet::new();
324    let mut result = Vec::new();
325    for v in filters {
326        let key = v.trim().to_lowercase();
327        if key.is_empty() {
328            continue;
329        }
330        let canonical = context_alias(&key).to_string();
331        if seen.insert(canonical.clone()) {
332            result.push(canonical);
333        }
334    }
335    result
336}
337
338pub fn infer_context_filters(question: &str) -> Vec<String> {
339    let lowered: HashSet<String> = question
340        .replace(['?', ','], " ")
341        .split_whitespace()
342        .map(|tok| tok.to_lowercase())
343        .collect();
344    let mut inferred = Vec::new();
345    for (context, hints) in CONTEXT_HINTS {
346        if hints.iter().any(|h| lowered.contains(*h)) {
347            inferred.push(context.to_string());
348        }
349    }
350    inferred
351}
352
353pub fn resolve_context_filters(
354    question: &str,
355    explicit_filters: &[String],
356) -> (Vec<String>, Option<String>) {
357    let normalized = normalize_context_filters(explicit_filters);
358    if !normalized.is_empty() {
359        return (normalized, Some("explicit".to_string()));
360    }
361    let inferred = infer_context_filters(question);
362    if !inferred.is_empty() {
363        return (inferred, Some("heuristic".to_string()));
364    }
365    (vec![], None)
366}
367
368pub fn filter_graph_by_context(g: &QueryGraph, filters: &[String]) -> QueryGraph {
369    let filter_set: HashSet<String> = normalize_context_filters(filters).into_iter().collect();
370    if filter_set.is_empty() {
371        // return a copy
372        let mut h = QueryGraph::new();
373        for (id, data) in &g.nodes {
374            h.add_node(id.clone(), data.clone());
375        }
376        for (u, neighbors) in &g.adj {
377            for (v, edge) in neighbors {
378                // avoid double-insertion: only add when u < v (lexicographic)
379                if u < v {
380                    h.adj
381                        .entry(u.clone())
382                        .or_default()
383                        .push((v.clone(), edge.clone()));
384                    h.adj.entry(v.clone()).or_default().push((
385                        u.clone(),
386                        EdgeData {
387                            relation: edge.relation.clone(),
388                            confidence: edge.confidence.clone(),
389                            context: edge.context.clone(),
390                        },
391                    ));
392                }
393            }
394        }
395        return h;
396    }
397
398    let mut h = QueryGraph::new();
399    for (id, data) in &g.nodes {
400        h.add_node(id.clone(), data.clone());
401    }
402    let mut added: HashSet<(String, String)> = HashSet::new();
403    for (u, neighbors) in &g.adj {
404        for (v, edge) in neighbors {
405            let matches = edge
406                .context
407                .as_deref()
408                .map(|c| filter_set.contains(c))
409                .unwrap_or(false);
410            if matches {
411                let key = if u <= v {
412                    (u.clone(), v.clone())
413                } else {
414                    (v.clone(), u.clone())
415                };
416                if added.insert(key) {
417                    h.adj
418                        .entry(u.clone())
419                        .or_default()
420                        .push((v.clone(), edge.clone()));
421                    h.adj.entry(v.clone()).or_default().push((
422                        u.clone(),
423                        EdgeData {
424                            relation: edge.relation.clone(),
425                            confidence: edge.confidence.clone(),
426                            context: edge.context.clone(),
427                        },
428                    ));
429                }
430            }
431        }
432    }
433    h
434}
435
436// ---- BFS / DFS ----
437
438pub fn bfs(
439    g: &QueryGraph,
440    start_nodes: &[String],
441    depth: usize,
442) -> (HashSet<String>, Vec<(String, String)>) {
443    let hub_threshold = g.hub_threshold();
444    let seed_set: HashSet<&str> = start_nodes.iter().map(|s| s.as_str()).collect();
445    let mut visited: HashSet<String> = start_nodes.iter().cloned().collect();
446    let mut frontier: HashSet<String> = start_nodes.iter().cloned().collect();
447    let mut edges_seen: Vec<(String, String)> = Vec::new();
448
449    for _ in 0..depth {
450        let mut next_frontier = HashSet::new();
451        for n in &frontier {
452            if !seed_set.contains(n.as_str()) && g.degree(n) >= hub_threshold {
453                continue;
454            }
455            for (neighbor, _) in g.neighbors(n) {
456                if !visited.contains(neighbor) {
457                    next_frontier.insert(neighbor.to_string());
458                    edges_seen.push((n.clone(), neighbor.to_string()));
459                }
460            }
461        }
462        visited.extend(next_frontier.iter().cloned());
463        frontier = next_frontier;
464        if frontier.is_empty() {
465            break;
466        }
467    }
468    (visited, edges_seen)
469}
470
471pub fn dfs(
472    g: &QueryGraph,
473    start_nodes: &[String],
474    depth: usize,
475) -> (HashSet<String>, Vec<(String, String)>) {
476    let hub_threshold = g.hub_threshold();
477    let seed_set: HashSet<&str> = start_nodes.iter().map(|s| s.as_str()).collect();
478    let mut visited: HashSet<String> = HashSet::new();
479    let mut edges_seen: Vec<(String, String)> = Vec::new();
480    let mut stack: Vec<(String, usize)> =
481        start_nodes.iter().rev().map(|n| (n.clone(), 0)).collect();
482
483    while let Some((node, d)) = stack.pop() {
484        if visited.contains(&node) || d > depth {
485            continue;
486        }
487        visited.insert(node.clone());
488        if !seed_set.contains(node.as_str()) && g.degree(&node) >= hub_threshold {
489            continue;
490        }
491        for (neighbor, _) in g.neighbors(&node) {
492            if !visited.contains(neighbor) {
493                stack.push((neighbor.to_string(), d + 1));
494                edges_seen.push((node.clone(), neighbor.to_string()));
495            }
496        }
497    }
498    (visited, edges_seen)
499}
500
501// ---- Subgraph to text ----
502
503pub fn subgraph_to_text(
504    g: &QueryGraph,
505    nodes: &HashSet<String>,
506    edges: &[(String, String)],
507    token_budget: usize,
508    seeds: Option<&[String]>,
509) -> String {
510    let char_budget = token_budget * 3;
511    let mut lines: Vec<String> = Vec::new();
512    let _seed_set: HashSet<&str> = seeds.unwrap_or(&[]).iter().map(|s| s.as_str()).collect();
513
514    let mut ordered: Vec<String> = seeds
515        .unwrap_or(&[])
516        .iter()
517        .filter(|n| nodes.contains(*n))
518        .cloned()
519        .collect();
520    let rest_set: HashSet<&str> = ordered.iter().map(|s| s.as_str()).collect();
521    let mut rest: Vec<String> = nodes
522        .iter()
523        .filter(|n| !rest_set.contains(n.as_str()))
524        .cloned()
525        .collect();
526    rest.sort_by_key(|n| std::cmp::Reverse(g.degree(n)));
527    ordered.extend(rest);
528
529    for nid in &ordered {
530        let d = g.nodes.get(nid.as_str());
531        let (label, src, loc, community) = match d {
532            Some(nd) => (
533                nd.label.as_str(),
534                nd.source_file.as_str(),
535                nd.source_location.as_str(),
536                nd.community.map(|c| c.to_string()),
537            ),
538            None => (nid.as_str(), "", "", None),
539        };
540        let line = format!(
541            "NODE {} [src={} loc={} community={}]",
542            sanitize_label(Some(label)),
543            sanitize_label(Some(src)),
544            sanitize_label(Some(loc)),
545            sanitize_label(community.as_deref()),
546        );
547        lines.push(line);
548    }
549
550    for (u, v) in edges {
551        if nodes.contains(u) && nodes.contains(v) {
552            let edge = g
553                .adj
554                .get(u)
555                .and_then(|adj| adj.iter().find(|(n, _)| n == v))
556                .map(|(_, e)| e);
557            let (relation, confidence, context) = match edge {
558                Some(e) => (
559                    e.relation.as_str(),
560                    e.confidence.as_str(),
561                    e.context.as_deref(),
562                ),
563                None => ("", "", None),
564            };
565            let context_suffix = if let Some(ctx) = context {
566                format!(" context={}", sanitize_label(Some(ctx)))
567            } else {
568                String::new()
569            };
570            let u_label = g
571                .nodes
572                .get(u)
573                .map(|d| d.label.as_str())
574                .unwrap_or(u.as_str());
575            let v_label = g
576                .nodes
577                .get(v)
578                .map(|d| d.label.as_str())
579                .unwrap_or(v.as_str());
580            let line = format!(
581                "EDGE {} --{} [{}{}]--> {}",
582                sanitize_label(Some(u_label)),
583                sanitize_label(Some(relation)),
584                sanitize_label(Some(confidence)),
585                context_suffix,
586                sanitize_label(Some(v_label)),
587            );
588            lines.push(line);
589        }
590    }
591
592    let output = lines.join("\n");
593    if output.len() > char_budget {
594        let cut_at = output[..char_budget]
595            .rfind('\n')
596            .filter(|&p| p > 0)
597            .unwrap_or(char_budget);
598        let total_nodes = lines.iter().filter(|l| l.starts_with("NODE ")).count();
599        let shown_nodes = output[..cut_at]
600            .split('\n')
601            .filter(|l| l.starts_with("NODE "))
602            .count();
603        let cut_count = total_nodes.saturating_sub(shown_nodes);
604        return format!(
605            "{}\n... (truncated — {} more nodes cut by ~{}-token budget. Narrow with context_filter=['call'] or use get_node for a specific symbol)",
606            &output[..cut_at],
607            cut_count,
608            token_budget,
609        );
610    }
611    output
612}
613
614// ---- Find node ----
615
616pub fn find_node(g: &QueryGraph, label: &str) -> Vec<String> {
617    let tokens = search_tokens(label);
618    let term = tokens.join(" ");
619    if term.is_empty() {
620        return vec![];
621    }
622    let mut exact = Vec::new();
623    let mut prefix = Vec::new();
624    let mut substring = Vec::new();
625    for (nid, data) in &g.nodes {
626        let norm_label = data.label.to_lowercase();
627        let bare_label = norm_label.trim_end_matches(['(', ')']).to_string();
628        let nid_lower = nid.to_lowercase();
629        if term == norm_label || term == bare_label || term == nid_lower {
630            exact.push(nid.clone());
631        } else if norm_label.starts_with(&term)
632            || bare_label.starts_with(&term)
633            || nid_lower.starts_with(&term)
634        {
635            prefix.push(nid.clone());
636        } else if norm_label.contains(&term) {
637            substring.push(nid.clone());
638        }
639    }
640    let mut result = exact;
641    result.extend(prefix);
642    result.extend(substring);
643    result
644}
645
646// ---- Communities ----
647
648pub fn communities_from_graph(g: &QueryGraph) -> HashMap<i64, Vec<String>> {
649    let mut communities: HashMap<i64, Vec<String>> = HashMap::new();
650    for (nid, data) in &g.nodes {
651        if let Some(cid) = data.community {
652            communities.entry(cid).or_default().push(nid.clone());
653        }
654    }
655    communities
656}
657
658// ---- Full query pipeline ----
659
660pub fn query_graph_text(
661    g: &mut QueryGraph,
662    question: &str,
663    mode: &str,
664    depth: usize,
665    token_budget: usize,
666    context_filters: &[String],
667) -> String {
668    let terms = query_terms(question);
669    let scored = score_nodes(g, &terms);
670    let start_nodes = pick_seeds(&scored, 3, 0.2);
671    if start_nodes.is_empty() {
672        return "No matching nodes found.".to_string();
673    }
674    let (resolved_filters, filter_source) = resolve_context_filters(question, context_filters);
675    let traversal_graph = filter_graph_by_context(g, &resolved_filters);
676    let (nodes, edges) = if mode == "dfs" {
677        dfs(&traversal_graph, &start_nodes, depth)
678    } else {
679        bfs(&traversal_graph, &start_nodes, depth)
680    };
681
682    let seed_labels: Vec<String> = start_nodes
683        .iter()
684        .map(|n| {
685            g.nodes
686                .get(n)
687                .map(|d| d.label.clone())
688                .unwrap_or_else(|| n.clone())
689        })
690        .collect();
691
692    let mut header_parts = vec![
693        format!("Traversal: {} depth={}", mode.to_uppercase(), depth),
694        format!("Start: {:?}", seed_labels),
695    ];
696    if !resolved_filters.is_empty() {
697        let src = filter_source.as_deref().unwrap_or("unknown");
698        header_parts.push(format!(
699            "Context: {} ({})",
700            resolved_filters.join(", "),
701            src
702        ));
703    }
704    header_parts.push(format!("{} nodes found", nodes.len()));
705    let header = header_parts.join(" | ") + "\n\n";
706    header
707        + &subgraph_to_text(
708            &traversal_graph,
709            &nodes,
710            &edges,
711            token_budget,
712            Some(&start_nodes),
713        )
714}
715
716// ---- Load graph from JSON ----
717
718pub fn load_graph_with_cap(path: &str, max_bytes: u64) -> Result<QueryGraph, String> {
719    use std::path::Path;
720    let resolved = Path::new(path);
721    if resolved.extension().and_then(|e| e.to_str()) != Some("json") {
722        return Err(format!("Graph path must be a .json file, got: {:?}", path));
723    }
724    if !resolved.exists() {
725        return Err(format!("Graph file not found: {}", path));
726    }
727    let meta = std::fs::metadata(resolved).map_err(|e| e.to_string())?;
728    if meta.len() > max_bytes {
729        let msg = format!(
730            "error: graph.json ({} bytes) exceeds {} byte cap",
731            meta.len(),
732            max_bytes
733        );
734        eprintln!("{}", msg);
735        return Err(msg);
736    }
737    let content = std::fs::read_to_string(resolved).map_err(|e| e.to_string())?;
738    let data: serde_json::Value = serde_json::from_str(&content)
739        .map_err(|e| format!("graph.json is corrupted ({}). Re-run to rebuild.", e))?;
740
741    let mut g = QueryGraph::new();
742
743    if let Some(nodes) = data.get("nodes").and_then(|v| v.as_array()) {
744        for node in nodes {
745            let id = node
746                .get("id")
747                .and_then(|v| v.as_str())
748                .unwrap_or("")
749                .to_string();
750            if id.is_empty() {
751                continue;
752            }
753            let label = node
754                .get("label")
755                .and_then(|v| v.as_str())
756                .unwrap_or(&id)
757                .to_string();
758            let source_file = node
759                .get("source_file")
760                .and_then(|v| v.as_str())
761                .unwrap_or("")
762                .to_string();
763            let source_location = node
764                .get("source_location")
765                .and_then(|v| v.as_str())
766                .unwrap_or("")
767                .to_string();
768            let community = node.get("community").and_then(|v| v.as_i64());
769            g.add_node(
770                id,
771                NodeData {
772                    label,
773                    source_file,
774                    source_location,
775                    community,
776                },
777            );
778        }
779    }
780
781    let links_key = if data.get("links").is_some() {
782        "links"
783    } else {
784        "edges"
785    };
786    if let Some(links) = data.get(links_key).and_then(|v| v.as_array()) {
787        for link in links {
788            let source = link
789                .get("source")
790                .and_then(|v| v.as_str())
791                .unwrap_or("")
792                .to_string();
793            let target = link
794                .get("target")
795                .and_then(|v| v.as_str())
796                .unwrap_or("")
797                .to_string();
798            if source.is_empty() || target.is_empty() {
799                continue;
800            }
801            let relation = link
802                .get("relation")
803                .and_then(|v| v.as_str())
804                .unwrap_or("")
805                .to_string();
806            let confidence = link
807                .get("confidence")
808                .and_then(|v| v.as_str())
809                .unwrap_or("")
810                .to_string();
811            let context = link
812                .get("context")
813                .and_then(|v| v.as_str())
814                .map(|s| s.to_string());
815            g.add_edge(
816                source,
817                target,
818                EdgeData {
819                    relation,
820                    confidence,
821                    context,
822                },
823            );
824        }
825    }
826
827    Ok(g)
828}
829
830pub fn load_graph(path: &str) -> Result<QueryGraph, String> {
831    load_graph_with_cap(path, MAX_GRAPH_FILE_BYTES)
832}
833
834// ---- Tests ----
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use std::io::Write;
840    use tempfile::NamedTempFile;
841
842    fn make_graph() -> QueryGraph {
843        let mut g = QueryGraph::new();
844        g.add_node(
845            "n1",
846            NodeData {
847                label: "extract".into(),
848                source_file: "extract.py".into(),
849                source_location: "L10".into(),
850                community: Some(0),
851            },
852        );
853        g.add_node(
854            "n2",
855            NodeData {
856                label: "cluster".into(),
857                source_file: "cluster.py".into(),
858                source_location: "L5".into(),
859                community: Some(0),
860            },
861        );
862        g.add_node(
863            "n3",
864            NodeData {
865                label: "build".into(),
866                source_file: "build.py".into(),
867                source_location: "L1".into(),
868                community: Some(1),
869            },
870        );
871        g.add_node(
872            "n4",
873            NodeData {
874                label: "report".into(),
875                source_file: "report.py".into(),
876                source_location: "L1".into(),
877                community: Some(1),
878            },
879        );
880        g.add_node(
881            "n5",
882            NodeData {
883                label: "isolated".into(),
884                source_file: "other.py".into(),
885                source_location: "L1".into(),
886                community: Some(2),
887            },
888        );
889        g.add_edge(
890            "n1",
891            "n2",
892            EdgeData {
893                relation: "calls".into(),
894                confidence: "INFERRED".into(),
895                context: Some("call".into()),
896            },
897        );
898        g.add_edge(
899            "n2",
900            "n3",
901            EdgeData {
902                relation: "imports".into(),
903                confidence: "EXTRACTED".into(),
904                context: Some("import".into()),
905            },
906        );
907        g.add_edge(
908            "n3",
909            "n4",
910            EdgeData {
911                relation: "uses".into(),
912                confidence: "EXTRACTED".into(),
913                context: None,
914            },
915        );
916        g
917    }
918
919    fn write_graph_json(nodes: &[&str]) -> NamedTempFile {
920        let nodes_json: Vec<serde_json::Value> = nodes
921            .iter()
922            .map(|n| {
923                serde_json::json!({
924                    "id": n,
925                    "label": n,
926                    "community": 0
927                })
928            })
929            .collect();
930        let data = serde_json::json!({
931            "directed": false,
932            "nodes": nodes_json,
933            "links": []
934        });
935        let mut f = NamedTempFile::with_suffix(".json").unwrap();
936        f.write_all(data.to_string().as_bytes()).unwrap();
937        f
938    }
939
940    // --- communities_from_graph ---
941
942    #[test]
943    fn test_communities_from_graph_basic() {
944        let g = make_graph();
945        let c = communities_from_graph(&g);
946        assert!(c.contains_key(&0));
947        assert!(c.contains_key(&1));
948        assert!(c[&0].contains(&"n1".to_string()));
949        assert!(c[&0].contains(&"n2".to_string()));
950        assert!(c[&1].contains(&"n3".to_string()));
951    }
952
953    #[test]
954    fn test_communities_from_graph_no_community_attr() {
955        let mut g = QueryGraph::new();
956        g.add_node(
957            "a",
958            NodeData {
959                label: "foo".into(),
960                source_file: "".into(),
961                source_location: "".into(),
962                community: None,
963            },
964        );
965        let c = communities_from_graph(&g);
966        assert!(c.is_empty());
967    }
968
969    #[test]
970    fn test_communities_from_graph_isolated() {
971        let g = make_graph();
972        let c = communities_from_graph(&g);
973        assert!(c.contains_key(&2));
974        assert!(c[&2].contains(&"n5".to_string()));
975    }
976
977    // --- score_nodes ---
978
979    #[test]
980    fn test_score_nodes_exact_label_match() {
981        let mut g = make_graph();
982        let scored = score_nodes(&mut g, &["extract".to_string()]);
983        let nids: Vec<&str> = scored.iter().map(|(_, n)| n.as_str()).collect();
984        assert!(nids.contains(&"n1"));
985        assert_eq!(scored[0].1, "n1");
986    }
987
988    #[test]
989    fn test_score_nodes_no_match() {
990        let mut g = make_graph();
991        let scored = score_nodes(&mut g, &["xyzzy".to_string()]);
992        assert!(scored.is_empty());
993    }
994
995    #[test]
996    fn test_score_nodes_source_file_partial() {
997        let mut g = make_graph();
998        let scored = score_nodes(&mut g, &["cluster".to_string()]);
999        let nids: Vec<&str> = scored.iter().map(|(_, n)| n.as_str()).collect();
1000        assert!(nids.contains(&"n2"));
1001    }
1002
1003    #[test]
1004    fn test_score_nodes_ignores_trailing_punctuation() {
1005        let mut g = make_graph();
1006        let scored = score_nodes(&mut g, &["extract?".to_string()]);
1007        assert!(!scored.is_empty());
1008        assert_eq!(scored[0].1, "n1");
1009    }
1010
1011    // --- find_node ---
1012
1013    #[test]
1014    fn test_find_node_ignores_trailing_punctuation() {
1015        let g = make_graph();
1016        let result = find_node(&g, "extract?");
1017        assert!(result.contains(&"n1".to_string()));
1018    }
1019
1020    // --- query_terms ---
1021
1022    #[test]
1023    fn test_query_terms_strips_search_punctuation() {
1024        let terms = query_terms("what calls extract?");
1025        assert!(terms.contains(&"what".to_string()));
1026        assert!(terms.contains(&"calls".to_string()));
1027        assert!(terms.contains(&"extract".to_string()));
1028        assert!(!terms.iter().any(|t| t.contains('?')));
1029    }
1030
1031    #[test]
1032    fn test_query_graph_text_keeps_short_non_english_terms() {
1033        let mut g = QueryGraph::new();
1034        g.add_node(
1035            "frontend",
1036            NodeData {
1037                label: "前端".into(),
1038                source_file: "docs/前端.md".into(),
1039                source_location: "L1".into(),
1040                community: Some(0),
1041            },
1042        );
1043        let text = query_graph_text(&mut g, "前端", "bfs", 1, 2000, &[]);
1044        assert!(!text.contains("No matching nodes found."));
1045        assert!(text.contains("NODE 前端"));
1046    }
1047
1048    #[test]
1049    fn test_infer_context_filters_for_calls_question() {
1050        let filters = infer_context_filters("who calls extract");
1051        assert!(filters.contains(&"call".to_string()));
1052    }
1053
1054    #[test]
1055    fn test_resolve_context_filters_explicit_overrides_heuristic() {
1056        let (filters, source) =
1057            resolve_context_filters("who calls extract", &["field".to_string()]);
1058        assert_eq!(filters, vec!["field"]);
1059        assert_eq!(source.as_deref(), Some("explicit"));
1060    }
1061
1062    // --- bfs ---
1063
1064    #[test]
1065    fn test_bfs_depth_1() {
1066        let g = make_graph();
1067        let (visited, _) = bfs(&g, &["n1".to_string()], 1);
1068        assert!(visited.contains("n1"));
1069        assert!(visited.contains("n2"));
1070        assert!(!visited.contains("n3"));
1071    }
1072
1073    #[test]
1074    fn test_bfs_depth_2() {
1075        let g = make_graph();
1076        let (visited, _) = bfs(&g, &["n1".to_string()], 2);
1077        assert!(visited.contains("n3"));
1078    }
1079
1080    #[test]
1081    fn test_bfs_disconnected() {
1082        let g = make_graph();
1083        let (visited, _) = bfs(&g, &["n5".to_string()], 3);
1084        assert_eq!(visited, HashSet::from(["n5".to_string()]));
1085    }
1086
1087    #[test]
1088    fn test_bfs_returns_edges() {
1089        let g = make_graph();
1090        let (_, edges) = bfs(&g, &["n1".to_string()], 1);
1091        assert!(!edges.is_empty());
1092        assert!(edges.iter().any(|(u, v)| u == "n1" || v == "n1"));
1093    }
1094
1095    #[test]
1096    fn test_filter_graph_by_context_limits_traversal() {
1097        let g = make_graph();
1098        let filtered = filter_graph_by_context(&g, &["call".to_string()]);
1099        let (visited, edges) = bfs(&filtered, &["n1".to_string()], 2);
1100        assert!(visited.contains("n2"));
1101        assert!(!visited.contains("n3"));
1102        assert_eq!(edges.len(), 1);
1103    }
1104
1105    // --- dfs ---
1106
1107    #[test]
1108    fn test_dfs_depth_1() {
1109        let g = make_graph();
1110        let (visited, _) = dfs(&g, &["n1".to_string()], 1);
1111        assert!(visited.contains("n1"));
1112        assert!(visited.contains("n2"));
1113        assert!(!visited.contains("n3"));
1114    }
1115
1116    #[test]
1117    fn test_dfs_full_chain() {
1118        let g = make_graph();
1119        let (visited, _) = dfs(&g, &["n1".to_string()], 5);
1120        for n in &["n1", "n2", "n3", "n4"] {
1121            assert!(visited.contains(*n), "missing {}", n);
1122        }
1123    }
1124
1125    // --- subgraph_to_text ---
1126
1127    #[test]
1128    fn test_subgraph_to_text_contains_labels() {
1129        let g = make_graph();
1130        let nodes: HashSet<String> = ["n1", "n2"].iter().map(|s| s.to_string()).collect();
1131        let edges = vec![("n1".to_string(), "n2".to_string())];
1132        let text = subgraph_to_text(&g, &nodes, &edges, 2000, None);
1133        assert!(text.contains("extract"));
1134        assert!(text.contains("cluster"));
1135    }
1136
1137    #[test]
1138    fn test_subgraph_to_text_truncates() {
1139        let g = make_graph();
1140        let nodes: HashSet<String> = ["n1", "n2", "n3", "n4"]
1141            .iter()
1142            .map(|s| s.to_string())
1143            .collect();
1144        let edges = vec![("n1".to_string(), "n2".to_string())];
1145        let text = subgraph_to_text(&g, &nodes, &edges, 1, None);
1146        assert!(text.contains("truncated"));
1147    }
1148
1149    #[test]
1150    fn test_subgraph_to_text_edge_included() {
1151        let g = make_graph();
1152        let nodes: HashSet<String> = ["n1", "n2"].iter().map(|s| s.to_string()).collect();
1153        let edges = vec![("n1".to_string(), "n2".to_string())];
1154        let text = subgraph_to_text(&g, &nodes, &edges, 2000, None);
1155        assert!(text.contains("EDGE"));
1156        assert!(text.contains("calls"));
1157    }
1158
1159    #[test]
1160    fn test_subgraph_to_text_includes_edge_context() {
1161        let g = make_graph();
1162        let nodes: HashSet<String> = ["n1", "n2"].iter().map(|s| s.to_string()).collect();
1163        let edges = vec![("n1".to_string(), "n2".to_string())];
1164        let text = subgraph_to_text(&g, &nodes, &edges, 2000, None);
1165        assert!(text.contains("context=call"));
1166    }
1167
1168    #[test]
1169    fn test_query_graph_text_explicit_context_filter_changes_traversal() {
1170        let mut g = make_graph();
1171        let text = query_graph_text(&mut g, "extract", "bfs", 2, 2000, &["call".to_string()]);
1172        assert!(text.contains("Context: call (explicit)"));
1173        assert!(text.contains("cluster"));
1174        assert!(!text.contains("build"));
1175    }
1176
1177    #[test]
1178    fn test_query_graph_text_heuristic_context_filter_changes_traversal() {
1179        let mut g = make_graph();
1180        let text = query_graph_text(&mut g, "who calls extract", "bfs", 2, 2000, &[]);
1181        assert!(text.contains("Context: call (heuristic)"));
1182        assert!(text.contains("cluster"));
1183        assert!(!text.contains("build"));
1184    }
1185
1186    // --- load_graph ---
1187
1188    #[test]
1189    fn test_load_graph_roundtrip() {
1190        let f = write_graph_json(&["alpha", "beta"]);
1191        let g = load_graph(f.path().to_str().unwrap()).unwrap();
1192        assert_eq!(g.node_count(), 2);
1193    }
1194
1195    #[test]
1196    fn test_load_graph_missing_file() {
1197        let result = load_graph("/tmp/does_not_exist_codesynapse.json");
1198        assert!(result.is_err());
1199    }
1200
1201    #[test]
1202    fn test_load_graph_rejects_oversized_file() {
1203        let f = write_graph_json(&["a"]);
1204        let result = load_graph_with_cap(f.path().to_str().unwrap(), 16);
1205        assert!(result.is_err());
1206        let msg = result.unwrap_err();
1207        assert!(msg.contains("exceeds") && msg.contains("byte cap"));
1208    }
1209
1210    #[test]
1211    fn test_load_graph_accepts_under_cap() {
1212        let f = write_graph_json(&["a"]);
1213        let result = load_graph_with_cap(f.path().to_str().unwrap(), 10 * 1024 * 1024);
1214        assert!(result.is_ok());
1215        assert_eq!(result.unwrap().node_count(), 1);
1216    }
1217
1218    #[test]
1219    fn test_maybe_reload_detects_graph_change() {
1220        let f1 = write_graph_json(&["alpha", "beta"]);
1221        let path = f1.path().to_str().unwrap();
1222        let g1 = load_graph(path).unwrap();
1223        assert_eq!(g1.node_count(), 2);
1224        // Write new content to same path
1225        let nodes_json: Vec<serde_json::Value> = ["alpha", "beta", "gamma"]
1226            .iter()
1227            .map(|n| serde_json::json!({"id": n, "label": n, "community": 0}))
1228            .collect();
1229        let data = serde_json::json!({"directed": false, "nodes": nodes_json, "links": []});
1230        std::fs::write(path, data.to_string()).unwrap();
1231        let g2 = load_graph(path).unwrap();
1232        assert!(g2.nodes.contains_key("gamma"));
1233    }
1234
1235    #[test]
1236    fn test_load_graph_cache_key_changes_with_content() {
1237        let f = write_graph_json(&["a"]);
1238        let path = f.path();
1239        let s1 = std::fs::metadata(path).unwrap();
1240        let key1 = (s1.modified().unwrap(), s1.len());
1241        std::thread::sleep(std::time::Duration::from_millis(10));
1242        let nodes_json = vec![
1243            serde_json::json!({"id": "a", "label": "a", "community": 0}),
1244            serde_json::json!({"id": "b", "label": "b", "community": 0}),
1245        ];
1246        let data = serde_json::json!({"directed": false, "nodes": nodes_json, "links": []});
1247        std::fs::write(path, data.to_string()).unwrap();
1248        let s2 = std::fs::metadata(path).unwrap();
1249        let key2 = (s2.modified().unwrap(), s2.len());
1250        assert_ne!(key1, key2, "stat key must change when file content changes");
1251    }
1252
1253    // --- IDF ---
1254
1255    fn make_noisy_graph() -> QueryGraph {
1256        let mut g = QueryGraph::new();
1257        for i in 0..20usize {
1258            let id = format!("err{}", i);
1259            g.add_node(
1260                id.clone(),
1261                NodeData {
1262                    label: format!("error_handler_{}", i),
1263                    source_file: format!("err{}.py", i),
1264                    source_location: "L1".into(),
1265                    community: Some(0),
1266                },
1267            );
1268            if i > 0 {
1269                let prev = format!("err{}", i - 1);
1270                g.add_edge(
1271                    prev,
1272                    id,
1273                    EdgeData {
1274                        relation: "calls".into(),
1275                        confidence: "EXTRACTED".into(),
1276                        context: Some("call".into()),
1277                    },
1278                );
1279            }
1280        }
1281        g.add_node(
1282            "fbs",
1283            NodeData {
1284                label: "FooBarService".into(),
1285                source_file: "service.py".into(),
1286                source_location: "L1".into(),
1287                community: Some(1),
1288            },
1289        );
1290        g.add_node(
1291            "fbs_dep",
1292            NodeData {
1293                label: "ServiceClient".into(),
1294                source_file: "client.py".into(),
1295                source_location: "L1".into(),
1296                community: Some(1),
1297            },
1298        );
1299        g.add_edge(
1300            "fbs",
1301            "fbs_dep",
1302            EdgeData {
1303                relation: "uses".into(),
1304                confidence: "EXTRACTED".into(),
1305                context: None,
1306            },
1307        );
1308        g
1309    }
1310
1311    #[test]
1312    fn test_idf_downweights_common_terms() {
1313        let mut g = make_noisy_graph();
1314        let scored = score_nodes(&mut g, &["foobarservice".to_string(), "error".to_string()]);
1315        assert!(!scored.is_empty());
1316        assert_eq!(scored[0].1, "fbs", "FooBarService should rank first");
1317    }
1318
1319    #[test]
1320    fn test_idf_cached_on_graph() {
1321        let mut g = make_graph();
1322        score_nodes(&mut g, &["extract".to_string()]);
1323        assert!(g.idf_cache.contains_key("extract"));
1324    }
1325
1326    #[test]
1327    fn test_idf_new_graph_starts_fresh() {
1328        let mut g1 = make_graph();
1329        let g2 = make_graph();
1330        score_nodes(&mut g1, &["extract".to_string()]);
1331        assert!(g1.idf_cache.contains_key("extract"));
1332        assert!(!g2.idf_cache.contains_key("extract"));
1333    }
1334
1335    #[test]
1336    fn test_idf_rare_term_gets_high_weight() {
1337        let mut g = make_graph();
1338        let idf = compute_idf(&mut g, &["extract".to_string()]);
1339        assert!(idf["extract"] > 1.0, "rare term should get IDF > 1");
1340    }
1341
1342    #[test]
1343    fn test_idf_common_term_gets_low_weight() {
1344        let mut g = QueryGraph::new();
1345        for i in 0..20usize {
1346            g.add_node(
1347                format!("n{}", i),
1348                NodeData {
1349                    label: format!("handle_{}", i),
1350                    source_file: format!("f{}.py", i),
1351                    source_location: "L1".into(),
1352                    community: None,
1353                },
1354            );
1355        }
1356        let mut g_ref = g;
1357        let idf = compute_idf(&mut g_ref, &["handle".to_string()]);
1358        assert!(idf["handle"] < 1.0, "common term should get IDF < 1");
1359    }
1360
1361    // --- pick_seeds ---
1362
1363    #[test]
1364    fn test_pick_seeds_dominant_identifier_gives_one_seed() {
1365        let scored = vec![
1366            (1000.0, "fbs".to_string()),
1367            (1.0, "err1".to_string()),
1368            (0.9, "err2".to_string()),
1369        ];
1370        let seeds = pick_seeds(&scored, 3, 0.2);
1371        assert_eq!(seeds, vec!["fbs"]);
1372    }
1373
1374    #[test]
1375    fn test_pick_seeds_close_scores_keeps_multiple() {
1376        let scored = vec![
1377            (10.0, "a".to_string()),
1378            (9.0, "b".to_string()),
1379            (8.5, "c".to_string()),
1380        ];
1381        let seeds = pick_seeds(&scored, 3, 0.2);
1382        assert_eq!(seeds.len(), 3);
1383    }
1384
1385    #[test]
1386    fn test_pick_seeds_empty() {
1387        assert!(pick_seeds(&[], 3, 0.2).is_empty());
1388    }
1389
1390    #[test]
1391    fn test_pick_seeds_single() {
1392        let scored = vec![(5.0, "x".to_string())];
1393        assert_eq!(pick_seeds(&scored, 3, 0.2), vec!["x"]);
1394    }
1395
1396    #[test]
1397    fn test_pick_seeds_respects_max_k() {
1398        let scored: Vec<(f64, String)> = (0..10).map(|i| (10.0, format!("n{}", i))).collect();
1399        let seeds = pick_seeds(&scored, 3, 0.2);
1400        assert_eq!(seeds.len(), 3);
1401    }
1402
1403    // --- truncation hint ---
1404
1405    #[test]
1406    fn test_subgraph_to_text_truncation_hint_is_actionable() {
1407        let g = make_graph();
1408        let nodes: HashSet<String> = ["n1", "n2", "n3", "n4"]
1409            .iter()
1410            .map(|s| s.to_string())
1411            .collect();
1412        let edges = vec![("n1".to_string(), "n2".to_string())];
1413        let text = subgraph_to_text(&g, &nodes, &edges, 1, None);
1414        assert!(text.contains("truncated"));
1415        assert!(text.contains("get_node") || text.contains("context_filter"));
1416    }
1417
1418    // --- identifier + noise query ---
1419
1420    #[test]
1421    fn test_query_seeds_from_identifier_not_noise() {
1422        let mut g = make_noisy_graph();
1423        let text = query_graph_text(&mut g, "FooBarService error handling", "bfs", 2, 2000, &[]);
1424        assert!(text.contains("FooBarService"));
1425        assert!(text.contains("ServiceClient"));
1426    }
1427
1428    // --- parameter_type context filter ---
1429
1430    #[test]
1431    fn test_query_graph_text_parameter_type_context_filter() {
1432        let mut g = QueryGraph::new();
1433        g.add_node(
1434            "process",
1435            NodeData {
1436                label: "process".into(),
1437                source_file: "sample.cs".into(),
1438                source_location: "L20".into(),
1439                community: None,
1440            },
1441        );
1442        g.add_node(
1443            "payload",
1444            NodeData {
1445                label: "Payload".into(),
1446                source_file: "sample.cs".into(),
1447                source_location: "L5".into(),
1448                community: None,
1449            },
1450        );
1451        g.add_node(
1452            "other",
1453            NodeData {
1454                label: "PayloadFactory".into(),
1455                source_file: "sample.cs".into(),
1456                source_location: "L40".into(),
1457                community: None,
1458            },
1459        );
1460        g.add_edge(
1461            "process",
1462            "payload",
1463            EdgeData {
1464                relation: "references".into(),
1465                confidence: "EXTRACTED".into(),
1466                context: Some("parameter_type".into()),
1467            },
1468        );
1469        g.add_edge(
1470            "process",
1471            "other",
1472            EdgeData {
1473                relation: "calls".into(),
1474                confidence: "EXTRACTED".into(),
1475                context: Some("call".into()),
1476            },
1477        );
1478        let text = query_graph_text(
1479            &mut g,
1480            "who accepts Payload",
1481            "bfs",
1482            2,
1483            2000,
1484            &["parameter_type".to_string()],
1485        );
1486        assert!(text.contains("parameter_type"));
1487        assert!(text.contains("Payload"));
1488        assert!(!text.contains("PayloadFactory"));
1489    }
1490
1491    // --- context filter aliases ---
1492
1493    #[test]
1494    fn test_query_graph_text_context_filter_aliases_resolve() {
1495        assert_eq!(
1496            normalize_context_filters(&["param".to_string()]),
1497            vec!["parameter_type"]
1498        );
1499        assert_eq!(
1500            normalize_context_filters(&["parameter".to_string()]),
1501            vec!["parameter_type"]
1502        );
1503        assert_eq!(
1504            normalize_context_filters(&["return".to_string()]),
1505            vec!["return_type"]
1506        );
1507        assert_eq!(
1508            normalize_context_filters(&["returns".to_string()]),
1509            vec!["return_type"]
1510        );
1511        assert_eq!(
1512            normalize_context_filters(&["generic".to_string()]),
1513            vec!["generic_arg"]
1514        );
1515        assert_eq!(
1516            normalize_context_filters(&["generics".to_string()]),
1517            vec!["generic_arg"]
1518        );
1519        assert_eq!(
1520            normalize_context_filters(&["annotation".to_string()]),
1521            vec!["attribute"]
1522        );
1523        assert_eq!(
1524            normalize_context_filters(&["decorator".to_string()]),
1525            vec!["attribute"]
1526        );
1527        assert_eq!(
1528            normalize_context_filters(&["parameter_type".to_string()]),
1529            vec!["parameter_type"]
1530        );
1531        assert_eq!(
1532            normalize_context_filters(&["field".to_string()]),
1533            vec!["field"]
1534        );
1535    }
1536
1537    // --- Chinese text ---
1538
1539    #[test]
1540    fn test_query_terms_chinese_mixed() {
1541        let terms = query_terms("前端 router 路由配置");
1542        assert!(terms.iter().any(|t| t.contains("前端") || t == "前端"));
1543        assert!(terms.contains(&"router".to_string()));
1544        assert!(terms.iter().any(|t| t.contains("路由")));
1545        assert!(terms.iter().any(|t| t.contains("配置")));
1546    }
1547
1548    #[test]
1549    fn test_query_terms_non_chinese_scripts_are_not_segmented() {
1550        assert!(!has_chinese("かなカナ한글"));
1551        let terms = query_terms("かなカナ한글");
1552        assert!(terms.contains(&"かなカナ한글".to_string()));
1553    }
1554
1555    #[test]
1556    fn test_query_terms_chinese_no_jieba_fallback() {
1557        let terms = query_terms("页面路由");
1558        assert!(terms.iter().any(|t| t.contains("页面")));
1559        assert!(terms.iter().any(|t| t.contains("路由")));
1560        assert!(terms.contains(&"页面路由".to_string()));
1561        assert_eq!(terms.len(), 4); // bigrams: 页面, 面路, 路由 + original
1562    }
1563
1564    #[test]
1565    fn test_score_nodes_chinese_substring_match() {
1566        let mut g = QueryGraph::new();
1567        g.add_node(
1568            "n1",
1569            NodeData {
1570                label: "路由桥接核对表".into(),
1571                source_file: "doc.md".into(),
1572                source_location: "L1".into(),
1573                community: Some(0),
1574            },
1575        );
1576        g.add_node(
1577            "n2",
1578            NodeData {
1579                label: "其他内容".into(),
1580                source_file: "doc.md".into(),
1581                source_location: "L1".into(),
1582                community: Some(0),
1583            },
1584        );
1585        let scored = score_nodes(&mut g, &["路由".to_string()]);
1586        let nids: Vec<&str> = scored.iter().map(|(_, n)| n.as_str()).collect();
1587        assert!(nids.contains(&"n1"));
1588        assert!(!nids.contains(&"n2"));
1589    }
1590
1591    #[test]
1592    fn test_query_text_chinese_finds_routing_nodes() {
1593        let mut g = QueryGraph::new();
1594        g.add_node(
1595            "parent",
1596            NodeData {
1597                label: "页面路由规范".into(),
1598                source_file: "doc.md".into(),
1599                source_location: "L1".into(),
1600                community: Some(0),
1601            },
1602        );
1603        g.add_node(
1604            "child",
1605            NodeData {
1606                label: "路由桥接核对表".into(),
1607                source_file: "doc.md".into(),
1608                source_location: "L10".into(),
1609                community: Some(0),
1610            },
1611        );
1612        g.add_edge(
1613            "parent",
1614            "child",
1615            EdgeData {
1616                relation: "contains".into(),
1617                confidence: "EXTRACTED".into(),
1618                context: None,
1619            },
1620        );
1621        let text = query_graph_text(&mut g, "页面路由", "bfs", 2, 2000, &[]);
1622        assert!(!text.contains("No matching nodes found."));
1623        assert!(text.contains("路由"));
1624    }
1625}