1use crate::security::{sanitize_label, MAX_GRAPH_FILE_BYTES};
2use std::collections::{HashMap, HashSet};
3
4#[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
101pub 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
172const 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
267static 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 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 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
436pub 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
501pub 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
614pub 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
646pub 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
658pub 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
716pub 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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); }
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}