1use codesynapse_core::analyze::Analyzer;
2use codesynapse_core::embedding::StaticEmbedder;
3use codesynapse_core::error::Result;
4use codesynapse_core::global_graph::global_list;
5use codesynapse_core::graph::{GraphStore, SledGraphStore, StoreBackend};
6use codesynapse_core::types::{Edge, Node};
7use codesynapse_serve::context::context_query;
8use codesynapse_serve::graph_query::{
9 load_graph, query_graph_text_hybrid, query_top_nodes, ServeEdge, ServeGraph, ServeNode,
10};
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14use std::collections::HashMap;
15use std::io::{self, BufRead, Write};
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18use std::time::SystemTime;
19use walkdir::WalkDir;
20
21#[derive(Serialize)]
26struct JsonRpcResponse {
27 jsonrpc: String,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 id: Option<Value>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 result: Option<Value>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 error: Option<JsonRpcError>,
34}
35
36#[derive(Serialize)]
37struct JsonRpcError {
38 code: i32,
39 message: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 data: Option<Value>,
42}
43
44#[derive(Deserialize)]
45struct JsonRpcRequest {
46 #[serde(default)]
47 id: Option<Value>,
48 method: String,
49 #[serde(default)]
50 params: Option<Value>,
51}
52
53pub const CODESYNAPSE_INSTRUCTIONS: &str = "\
58Use Codesynapse MCP tools before reading files or answering architecture questions.\n\
59NEVER use grep, find, Bash, or subagents to answer 'how does X work' questions.\n\
60Subagents do not inherit MCP — they fall back to grep. Always use tools directly.\n\
61\n\
62Tool selection — follow this hierarchy exactly:\n\
63\n\
64STEP 1 — ALWAYS call codesynapse_context FIRST for ANY of these:\n\
65 'How does X work?', 'What handles Y?', 'Where is Z?', 'Explain the mechanism for...'\n\
66 Natural language OR symbol name both work.\n\
67 If result does NOT start with '[No exact match' → answer directly from it.\n\
68\n\
69STEP 2 — If codesynapse_context starts with '[No exact match — showing semantic results]':\n\
70 Identify specific type/function names in the results (CamelCase classes, snake_case functions).\n\
71 Call codesynapse_context(\"ExactName\") with EACH name — exact match returns callers+callees.\n\
72 For request flow questions this surfaces ALL callers of a service in one call.\n\
73 If no names found → call codesynapse_resolve(\"question\") for deeper hybrid search.\n\
74\n\
75STEP 2b — If exploring a multi-component mechanism (scheduler, I/O pipeline, runtime):\n\
76 If your context results are ALL from the SAME directory or file after 2-3 calls,\n\
77 you are seeing ONE layer — pivot and search adjacent components:\n\
78 e.g. found sync/waker → also search runtime/scheduler and runtime/task\n\
79 e.g. found middleware → also search router and handler\n\
80 Use codesynapse_read_method(\"TypeName\", \"methodName\") to get COMPLETE method bodies\n\
81 when context returns a partial snippet and you need full accuracy.\n\
82\n\
83STEP 3 — codesynapse_query_vector: ONLY for structural ownership questions:\n\
84 'Which module owns X?', 'What class manages Y?' — NOT for mechanism questions.\n\
85 Do NOT grep/find to verify graph results — trust the graph.\n\
86\n\
87Other tools (use only when specifically needed):\n\
88 About to change a class → codesynapse_blast_radius(\"ClassName\")\n\
89 Multiple classes changing (PR review) → codesynapse_blast_radius_multi([\"A\",\"B\",\"C\"])\n\
90 Blast radius + risk scores → codesynapse_blast_radius_scored(\"ClassName\")\n\
91 Class inheritance tree → codesynapse_hierarchy(\"ClassName\")\n\
92 Overview of a module → codesynapse_list_graphs() then codesynapse_module_summary(\"name\")\n\
93 Read/edit a class → codesynapse_outline(\"ClassName\") then codesynapse_read(...)\n\
94 Know exact method to read → codesynapse_read_method(\"ClassName\", \"methodName\")\n\
95 Need method + what it calls → codesynapse_read_with_callees(\"ClassName\", \"methodName\")\n\
96 Who calls X.method? → codesynapse_find_callers(\"ClassName\", \"methodName\")\n\
97 Request flow trace (controller→svc→DB) → context(\"ExactServiceClass\") — shows callers+callees\n\
98 All files referencing a class → codesynapse_find_usages(\"ClassName\")\n\
99 Graph stale after module refresh → codesynapse_build()\n\
100 Session usage + token savings → codesynapse_stats()\n\
101\n\
102Graph selection:\n\
103 Default graph is \"merged\" (all modules combined). Use a module-specific graph\n\
104 when merged returns too many results or unrelated modules bleed in (>500 nodes):\n\
105 codesynapse_list_graphs() — see all modules with node counts\n\
106 codesynapse_query_vector(\"X\", graph=\"mymodule\") — scope to one module\n\
107 codesynapse_module_summary(\"mymodule\") — overview before diving in\n\
108 If results keep returning the wrong module → remove it:\n\
109 codesynapse module remove <name>\n\
110\n\
111Add a new module:\n\
112 1. In terminal: codesynapse module add <name> /absolute/path\n\
113 2. Back here: codesynapse_build()";
114
115#[derive(Serialize)]
120struct ToolDef {
121 name: String,
122 description: String,
123 input_schema: Value,
124}
125
126fn tool_defs() -> Vec<ToolDef> {
127 vec![
128 ToolDef {
129 name: "codesynapse_query_graph".into(),
130 description: "Query the knowledge graph using natural language".into(),
131 input_schema: serde_json::json!({
132 "type": "object",
133 "properties": {
134 "question": {"type": "string"},
135 "budget": {"type": "integer", "default": 5}
136 },
137 "required": ["question"]
138 }),
139 },
140 ToolDef {
141 name: "codesynapse_get_node".into(),
142 description: "Get a node by its ID".into(),
143 input_schema: serde_json::json!({
144 "type": "object",
145 "properties": {"node_id": {"type": "string"}},
146 "required": ["node_id"]
147 }),
148 },
149 ToolDef {
150 name: "codesynapse_get_neighbors".into(),
151 description: "Get neighbors of a node up to a given depth".into(),
152 input_schema: serde_json::json!({
153 "type": "object",
154 "properties": {
155 "node_id": {"type": "string"},
156 "depth": {"type": "integer", "default": 1},
157 "limit": {"type": "integer", "default": 50},
158 "offset": {"type": "integer", "default": 0}
159 },
160 "required": ["node_id"]
161 }),
162 },
163 ToolDef {
164 name: "codesynapse_get_community".into(),
165 description: "Get nodes in a community by ID".into(),
166 input_schema: serde_json::json!({
167 "type": "object",
168 "properties": {
169 "community_id": {"type": "integer"},
170 "limit": {"type": "integer", "default": 50},
171 "offset": {"type": "integer", "default": 0}
172 },
173 "required": ["community_id"]
174 }),
175 },
176 ToolDef {
177 name: "codesynapse_god_nodes".into(),
178 description: "Find the most connected (god) nodes".into(),
179 input_schema: serde_json::json!({
180 "type": "object",
181 "properties": {"top_n": {"type": "integer", "default": 10}},
182 "required": []
183 }),
184 },
185 ToolDef {
186 name: "codesynapse_graph_stats".into(),
187 description: "Get overall graph statistics".into(),
188 input_schema: serde_json::json!({
189 "type": "object",
190 "properties": {},
191 "required": []
192 }),
193 },
194 ToolDef {
195 name: "codesynapse_shortest_path".into(),
196 description: "Find the shortest path between two nodes (BFS)".into(),
197 input_schema: serde_json::json!({
198 "type": "object",
199 "properties": {
200 "source": {"type": "string"},
201 "target": {"type": "string"}
202 },
203 "required": ["source", "target"]
204 }),
205 },
206 ToolDef {
207 name: "codesynapse_find_all_paths".into(),
208 description: "Find all paths between two nodes up to a max length".into(),
209 input_schema: serde_json::json!({
210 "type": "object",
211 "properties": {
212 "source": {"type": "string"},
213 "target": {"type": "string"},
214 "max_length": {"type": "integer", "default": 5}
215 },
216 "required": ["source", "target"]
217 }),
218 },
219 ToolDef {
220 name: "codesynapse_weighted_path".into(),
221 description: "Find the weighted shortest path using Dijkstra".into(),
222 input_schema: serde_json::json!({
223 "type": "object",
224 "properties": {
225 "source": {"type": "string"},
226 "target": {"type": "string"},
227 "min_confidence": {"type": "number", "default": 0.0}
228 },
229 "required": ["source", "target"]
230 }),
231 },
232 ToolDef {
233 name: "codesynapse_community_bridges".into(),
234 description: "Find bridge edges that connect communities".into(),
235 input_schema: serde_json::json!({
236 "type": "object",
237 "properties": {"top_n": {"type": "integer", "default": 10}},
238 "required": []
239 }),
240 },
241 ToolDef {
242 name: "codesynapse_diff".into(),
243 description: "Compare two graphs and find added/removed edges".into(),
244 input_schema: serde_json::json!({
245 "type": "object",
246 "properties": {"other_graph": {"type": "string"}},
247 "required": ["other_graph"]
248 }),
249 },
250 ToolDef {
251 name: "codesynapse_pagerank".into(),
252 description: "Compute PageRank scores for all nodes".into(),
253 input_schema: serde_json::json!({
254 "type": "object",
255 "properties": {
256 "top_n": {"type": "integer", "default": 10},
257 "damping": {"type": "number", "default": 0.85},
258 "max_iter": {"type": "integer", "default": 100}
259 },
260 "required": []
261 }),
262 },
263 ToolDef {
264 name: "codesynapse_detect_cycles".into(),
265 description: "Detect cycles (strongly connected components) in the graph".into(),
266 input_schema: serde_json::json!({
267 "type": "object",
268 "properties": {"max_cycles": {"type": "integer", "default": 10}},
269 "required": []
270 }),
271 },
272 ToolDef {
273 name: "codesynapse_smart_summary".into(),
274 description: "Generate a multi-level summary of the graph".into(),
275 input_schema: serde_json::json!({
276 "type": "object",
277 "properties": {
278 "level": {"type": "string", "enum": ["detailed", "community", "architecture"], "default": "architecture"},
279 "budget": {"type": "integer", "default": 100}
280 },
281 "required": []
282 }),
283 },
284 ToolDef {
285 name: "codesynapse_find_similar".into(),
286 description: "Find structurally similar nodes (requires Node2Vec)".into(),
287 input_schema: serde_json::json!({
288 "type": "object",
289 "properties": {
290 "top_n": {"type": "integer", "default": 10},
291 "node_id": {"type": "string"}
292 },
293 "required": ["node_id"]
294 }),
295 },
296 ToolDef {
297 name: "codesynapse_query_vector".into(),
298 description: "Structural ownership search — use ONLY when the question is 'what class/module handles X?' or 'what manages Y?' where you need to find which component owns a concept. NOT for mechanism questions ('how does X work') — use codesynapse_context for those. Returns a ranked node list, no source bodies.".into(),
299 input_schema: serde_json::json!({
300 "type": "object",
301 "properties": {
302 "query": {"type": "string"},
303 "graph": {"type": "string", "default": "merged"},
304 "top_k": {"type": "integer", "default": 8}
305 },
306 "required": ["query"]
307 }),
308 },
309 ToolDef {
310 name: "codesynapse_blast_radius".into(),
311 description: "Find all nodes reachable from a class within N hops".into(),
312 input_schema: serde_json::json!({
313 "type": "object",
314 "properties": {
315 "class_name": {"type": "string"},
316 "graph": {"type": "string", "default": "merged"},
317 "depth": {"type": "integer", "default": 3}
318 },
319 "required": ["class_name"]
320 }),
321 },
322 ToolDef {
323 name: "codesynapse_blast_radius_scored".into(),
324 description: "Blast radius of a class with risk scores (0.0–1.0) per affected node. Risk factors: security/payment keywords, high in-degree, no test coverage. Sorted HIGH→MEDIUM→LOW.".into(),
325 input_schema: serde_json::json!({
326 "type": "object",
327 "properties": {
328 "class_name": {"type": "string"},
329 "graph": {"type": "string", "default": "merged"},
330 "depth": {"type": "integer", "default": 3}
331 },
332 "required": ["class_name"]
333 }),
334 },
335 ToolDef {
336 name: "codesynapse_blast_radius_multi".into(),
337 description: "Combined blast radius for multiple classes in one call. BFS from all seeds simultaneously, union of affected nodes grouped by hop distance.".into(),
338 input_schema: serde_json::json!({
339 "type": "object",
340 "properties": {
341 "class_names": {"type": "array", "items": {"type": "string"}},
342 "graph": {"type": "string", "default": "merged"},
343 "depth": {"type": "integer", "default": 3}
344 },
345 "required": ["class_names"]
346 }),
347 },
348 ToolDef {
349 name: "codesynapse_query_semantic".into(),
350 description: "Traverse semantically_similar_to edges from seed nodes. Finds functionally related nodes by meaning. Requires graph built with --llm flag; returns graceful fallback if no semantic edges exist.".into(),
351 input_schema: serde_json::json!({
352 "type": "object",
353 "properties": {
354 "query": {"type": "string"},
355 "graph": {"type": "string", "default": "merged"},
356 "depth": {"type": "integer", "default": 2},
357 "min_confidence": {"type": "number", "default": 0.7}
358 },
359 "required": ["query"]
360 }),
361 },
362 ToolDef {
363 name: "codesynapse_hierarchy".into(),
364 description: "Show class inheritance tree (supertypes and implementors)".into(),
365 input_schema: serde_json::json!({
366 "type": "object",
367 "properties": {
368 "class_name": {"type": "string"},
369 "graph": {"type": "string", "default": "merged"}
370 },
371 "required": ["class_name"]
372 }),
373 },
374 ToolDef {
375 name: "codesynapse_list_graphs".into(),
376 description: "List all registered graph modules with node/edge counts".into(),
377 input_schema: serde_json::json!({
378 "type": "object",
379 "properties": {},
380 "required": []
381 }),
382 },
383 ToolDef {
384 name: "codesynapse_module_summary".into(),
385 description: "Node count, edge count, top god-nodes and language breakdown for a module".into(),
386 input_schema: serde_json::json!({
387 "type": "object",
388 "properties": {
389 "module": {"type": "string"}
390 },
391 "required": ["module"]
392 }),
393 },
394 ToolDef {
395 name: "codesynapse_outline".into(),
396 description: "Get compact structural outline of a class (methods, fields, line numbers) via the knowledge graph".into(),
397 input_schema: serde_json::json!({
398 "type": "object",
399 "properties": {
400 "class_name": {"type": "string"},
401 "graph": {"type": "string", "default": "merged"}
402 },
403 "required": ["class_name"]
404 }),
405 },
406 ToolDef {
407 name: "codesynapse_read".into(),
408 description: "Read specific lines from a class's source file resolved via the knowledge graph".into(),
409 input_schema: serde_json::json!({
410 "type": "object",
411 "properties": {
412 "class_name": {"type": "string"},
413 "from_line": {"type": "integer", "default": 1},
414 "to_line": {"type": "integer", "default": 0},
415 "graph": {"type": "string", "default": "merged"}
416 },
417 "required": ["class_name"]
418 }),
419 },
420 ToolDef {
421 name: "codesynapse_read_method".into(),
422 description: "Read a specific method body — resolves class → file → finds method via brace tracking".into(),
423 input_schema: serde_json::json!({
424 "type": "object",
425 "properties": {
426 "class_name": {"type": "string"},
427 "method_name": {"type": "string"},
428 "graph": {"type": "string", "default": "merged"}
429 },
430 "required": ["class_name", "method_name"]
431 }),
432 },
433 ToolDef {
434 name: "codesynapse_read_with_callees".into(),
435 description: "Read a method AND inline bodies of same-class methods it calls".into(),
436 input_schema: serde_json::json!({
437 "type": "object",
438 "properties": {
439 "class_name": {"type": "string"},
440 "method_name": {"type": "string"},
441 "depth": {"type": "integer", "default": 1},
442 "graph": {"type": "string", "default": "merged"}
443 },
444 "required": ["class_name", "method_name"]
445 }),
446 },
447 ToolDef {
448 name: "codesynapse_find_callers".into(),
449 description: "Find all callers of a class or method via graph edges, then source text search fallback".into(),
450 input_schema: serde_json::json!({
451 "type": "object",
452 "properties": {
453 "class_name": {"type": "string"},
454 "method_name": {"type": "string", "default": ""},
455 "graph": {"type": "string", "default": "merged"}
456 },
457 "required": ["class_name"]
458 }),
459 },
460 ToolDef {
461 name: "codesynapse_find_usages".into(),
462 description: "Find all source files that reference a class (imports, fields, parameters, annotations)".into(),
463 input_schema: serde_json::json!({
464 "type": "object",
465 "properties": {
466 "class_name": {"type": "string"},
467 "graph": {"type": "string", "default": "merged"}
468 },
469 "required": ["class_name"]
470 }),
471 },
472 ToolDef {
473 name: "codesynapse_context".into(),
474 description: concat!(
475 "PRIMARY tool — call this FIRST for ALL mechanism, architecture, and 'how does X work' questions. ",
476 "Accepts natural language or a symbol name. ",
477 "Finds entry points via symbol matching, expands one hop via call graph edges, ",
478 "and returns full source bodies with line numbers. ",
479 "Falls back to semantic search when no exact symbol match is found. ",
480 "Answer directly from this output — do NOT call resolve or query_vector afterward. ",
481 "For request flow questions: if result is semantic fallback, call context(\"ExactClassName\") ",
482 "with specific class names found in results — exact name triggers call-graph expansion, ",
483 "showing all callers (e.g. every controller that uses a service) and callees in one shot."
484 ).into(),
485 input_schema: serde_json::json!({
486 "type": "object",
487 "properties": {
488 "query": {"type": "string", "description": "Natural-language question or symbol name — accepts both"},
489 "graph": {"type": "string", "default": "merged"},
490 "max_chars": {"type": "integer", "default": 16000, "description": "Response size cap"}
491 },
492 "required": ["query"]
493 }),
494 },
495 ToolDef {
496 name: "codesynapse_resolve".into(),
497 description: concat!(
498 "FALLBACK ONLY — use only when codesynapse_context returns empty or no results. ",
499 "Runs hybrid BM25+dense search and returns outlines + top method bodies. ",
500 "Do NOT use as the first tool for 'how does X work' questions — use codesynapse_context first."
501 ).into(),
502 input_schema: serde_json::json!({
503 "type": "object",
504 "properties": {
505 "query": {"type": "string", "description": "Natural-language question or concept"},
506 "graph": {"type": "string", "default": "merged"},
507 "top_k": {"type": "integer", "default": 5, "description": "Max classes to include"},
508 "max_chars": {"type": "integer", "default": 24000, "description": "Response size cap"}
509 },
510 "required": ["query"]
511 }),
512 },
513 ToolDef {
514 name: "codesynapse_build".into(),
515 description: "Reload the knowledge graph from disk — call after bootstrap to pick up changes without MCP restart".into(),
516 input_schema: serde_json::json!({
517 "type": "object",
518 "properties": {
519 "module": {"type": "string", "default": ""}
520 },
521 "required": []
522 }),
523 },
524 ToolDef {
525 name: "codesynapse_stats".into(),
526 description: "Show codesynapse tool usage and estimated token savings across all sessions".into(),
527 input_schema: serde_json::json!({
528 "type": "object",
529 "properties": {},
530 "required": []
531 }),
532 },
533 ]
534}
535
536#[derive(Clone)]
541pub(crate) struct OutlineItem {
542 kind: String,
543 name: String,
544 line: usize, }
546
547fn outline_items(path: &Path, content: &str) -> Vec<OutlineItem> {
548 let ext = path
549 .extension()
550 .and_then(|e| e.to_str())
551 .unwrap_or("")
552 .to_lowercase();
553 match ext.as_str() {
554 "py" => outline_python(content),
555 "js" | "ts" | "tsx" | "jsx" => outline_js(content),
556 "rs" => outline_rust(content),
557 "go" => outline_go(content),
558 "rb" => outline_ruby(content),
559 "ex" | "exs" => outline_elixir(content),
560 _ => outline_java(content),
561 }
562}
563
564fn outline_java(content: &str) -> Vec<OutlineItem> {
565 let class_re = Regex::new(
566 r"(?i)^\s*(?:(?:public|protected|private|abstract|final|static)\s+)*(class|interface|enum|record)\s+(\w+)"
567 ).unwrap();
568 let method_re = Regex::new(
569 r"^\s*(?:(?:public|protected|private|static|final|abstract|synchronized|native|default|override)\s+)+(?:[\w<>\[\],\s]+\s+)(\w+)\s*\("
570 ).unwrap();
571 let field_re = Regex::new(
572 r"^\s*(?:(?:public|protected|private|static|final|volatile|transient)\s+)+([\w<>\[\]]+)\s+(\w+)\s*[=;]"
573 ).unwrap();
574 let mut items = Vec::new();
575 for (i, line) in content.lines().enumerate() {
576 let lineno = i + 1;
577 if let Some(cap) = class_re.captures(line) {
578 items.push(OutlineItem {
579 kind: cap[1].to_lowercase(),
580 name: cap[2].to_string(),
581 line: lineno,
582 });
583 } else if let Some(cap) = method_re.captures(line) {
584 let name = cap[1].to_string();
585 if !line.split('(').next().is_some_and(|s| s.contains('=')) {
586 items.push(OutlineItem {
587 kind: "method".into(),
588 name,
589 line: lineno,
590 });
591 }
592 } else if let Some(cap) = field_re.captures(line) {
593 items.push(OutlineItem {
594 kind: "field".into(),
595 name: cap[2].to_string(),
596 line: lineno,
597 });
598 }
599 }
600 items
601}
602
603fn outline_python(content: &str) -> Vec<OutlineItem> {
604 let class_re = Regex::new(r"^class\s+(\w+)").unwrap();
605 let method_re = Regex::new(r"^(\s+)def\s+(\w+)\s*\(").unwrap();
606 let func_re = Regex::new(r"^def\s+(\w+)\s*\(").unwrap();
607 let mut items = Vec::new();
608 for (i, line) in content.lines().enumerate() {
609 let lineno = i + 1;
610 if let Some(cap) = class_re.captures(line) {
611 items.push(OutlineItem {
612 kind: "class".into(),
613 name: cap[1].into(),
614 line: lineno,
615 });
616 } else if let Some(cap) = method_re.captures(line) {
617 items.push(OutlineItem {
618 kind: "method".into(),
619 name: cap[2].into(),
620 line: lineno,
621 });
622 } else if let Some(cap) = func_re.captures(line) {
623 items.push(OutlineItem {
624 kind: "method".into(),
625 name: cap[1].into(),
626 line: lineno,
627 });
628 }
629 }
630 items
631}
632
633fn outline_js(content: &str) -> Vec<OutlineItem> {
634 const KEYWORDS: &[&str] = &[
635 "if",
636 "for",
637 "while",
638 "switch",
639 "catch",
640 "return",
641 "const",
642 "let",
643 "var",
644 "import",
645 "export",
646 "new",
647 "throw",
648 "case",
649 "default",
650 "else",
651 "try",
652 "do",
653 "typeof",
654 "instanceof",
655 "function",
656 ];
657 let class_re = Regex::new(
658 r"^\s*(?:export\s+(?:default\s+)?)?(?:abstract\s+)?(class|interface|type|enum)\s+(\w+)",
659 )
660 .unwrap();
661 let method_re = Regex::new(r"^\s*(?:(?:async|static|get|set)\s+)*(\w+)\s*\(").unwrap();
662 let field_re =
663 Regex::new(r"^\s*(?:(?:public|private|protected|readonly|static)\s+)+(\w+)\s*[?!:=]")
664 .unwrap();
665 let mut items = Vec::new();
666 for (i, line) in content.lines().enumerate() {
667 let lineno = i + 1;
668 let stripped = line.trim();
669 if stripped.starts_with("//") || stripped.starts_with('*') {
670 continue;
671 }
672 if let Some(cap) = class_re.captures(line) {
673 items.push(OutlineItem {
674 kind: cap[1].to_lowercase(),
675 name: cap[2].into(),
676 line: lineno,
677 });
678 } else if line.contains('(') {
679 if let Some(cap) = method_re.captures(line) {
680 let name = cap[1].to_string();
681 if !KEYWORDS.contains(&name.as_str()) {
682 items.push(OutlineItem {
683 kind: "method".into(),
684 name,
685 line: lineno,
686 });
687 }
688 }
689 } else if let Some(cap) = field_re.captures(line) {
690 let name = cap[1].to_string();
691 if !KEYWORDS.contains(&name.as_str()) {
692 items.push(OutlineItem {
693 kind: "field".into(),
694 name,
695 line: lineno,
696 });
697 }
698 }
699 }
700 items
701}
702
703fn outline_rust(content: &str) -> Vec<OutlineItem> {
704 let type_re =
705 Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|type)\s+(\w+)").unwrap();
706 let impl_for_re = Regex::new(r"^\s*impl[^{]*\bfor\s+(\w+)").unwrap();
708 let impl_re = Regex::new(r"^\s*impl(?:\s*<[^>]*>)?\s+(\w+)").unwrap();
709 let fn_re = Regex::new(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+(\w+)").unwrap();
710
711 let mut items = Vec::new();
712 for (i, line) in content.lines().enumerate() {
713 let lineno = i + 1;
714 let stripped = line.trim();
715 if stripped.starts_with("//") || stripped.starts_with('*') || stripped.starts_with('#') {
716 continue;
717 }
718 if let Some(cap) = type_re.captures(line) {
719 let kind = if line.contains("struct") {
720 "class"
721 } else if line.contains("trait") {
722 "interface"
723 } else {
724 "enum"
725 };
726 items.push(OutlineItem {
727 kind: kind.into(),
728 name: cap[1].into(),
729 line: lineno,
730 });
731 } else if let Some(cap) = impl_for_re.captures(line) {
732 items.push(OutlineItem {
733 kind: "class".into(),
734 name: cap[1].into(),
735 line: lineno,
736 });
737 } else if let Some(cap) = impl_re.captures(line) {
738 items.push(OutlineItem {
739 kind: "class".into(),
740 name: cap[1].into(),
741 line: lineno,
742 });
743 } else if let Some(cap) = fn_re.captures(line) {
744 items.push(OutlineItem {
745 kind: "method".into(),
746 name: cap[1].into(),
747 line: lineno,
748 });
749 }
750 }
751 items
752}
753
754fn outline_go(content: &str) -> Vec<OutlineItem> {
755 let type_re = Regex::new(r"^\s*type\s+(\w+)\s+(?:struct|interface)").unwrap();
756 let fn_re = Regex::new(r"^\s*func\s+(?:\([^)]*\)\s+)?(\w+)\s*\(").unwrap();
757
758 let mut items = Vec::new();
759 for (i, line) in content.lines().enumerate() {
760 let lineno = i + 1;
761 let stripped = line.trim();
762 if stripped.starts_with("//") {
763 continue;
764 }
765 if let Some(cap) = type_re.captures(line) {
766 let kind = if line.contains("interface") {
767 "interface"
768 } else {
769 "class"
770 };
771 items.push(OutlineItem {
772 kind: kind.into(),
773 name: cap[1].into(),
774 line: lineno,
775 });
776 } else if let Some(cap) = fn_re.captures(line) {
777 items.push(OutlineItem {
778 kind: "method".into(),
779 name: cap[1].into(),
780 line: lineno,
781 });
782 }
783 }
784 items
785}
786
787fn outline_ruby(content: &str) -> Vec<OutlineItem> {
788 let class_re = Regex::new(r"^\s*(?:class|module)\s+(\w+)").unwrap();
789 let method_re = Regex::new(r"^\s*def\s+(self\.)?(\w+)").unwrap();
790
791 let mut items = Vec::new();
792 for (i, line) in content.lines().enumerate() {
793 let lineno = i + 1;
794 let stripped = line.trim();
795 if stripped.starts_with('#') {
796 continue;
797 }
798 if let Some(cap) = class_re.captures(line) {
799 items.push(OutlineItem {
800 kind: "class".into(),
801 name: cap[1].into(),
802 line: lineno,
803 });
804 } else if let Some(cap) = method_re.captures(line) {
805 items.push(OutlineItem {
806 kind: "method".into(),
807 name: cap[2].into(),
808 line: lineno,
809 });
810 }
811 }
812 items
813}
814
815fn outline_elixir(content: &str) -> Vec<OutlineItem> {
816 let module_re = Regex::new(r"^\s*defmodule\s+([\w.]+)").unwrap();
817 let fn_re = Regex::new(r"^\s*(?:def|defp|defmacro|defmacrop)\s+(\w+)").unwrap();
818
819 let mut items = Vec::new();
820 for (i, line) in content.lines().enumerate() {
821 let lineno = i + 1;
822 let stripped = line.trim();
823 if stripped.starts_with('#') {
824 continue;
825 }
826 if let Some(cap) = module_re.captures(line) {
827 items.push(OutlineItem {
828 kind: "class".into(),
829 name: cap[1].into(),
830 line: lineno,
831 });
832 } else if let Some(cap) = fn_re.captures(line) {
833 items.push(OutlineItem {
834 kind: "method".into(),
835 name: cap[1].into(),
836 line: lineno,
837 });
838 }
839 }
840 items
841}
842
843fn detect_method_end(lines: &[&str], start: usize) -> usize {
844 let mut depth = 0i32;
845 let mut opened = false;
846 for (i, line) in lines[start..].iter().enumerate() {
847 depth += line.chars().filter(|&c| c == '{').count() as i32;
848 depth -= line.chars().filter(|&c| c == '}').count() as i32;
849 if depth > 0 {
850 opened = true;
851 }
852 if opened && depth <= 0 {
853 return start + i;
854 }
855 }
856 lines.len().saturating_sub(1)
857}
858
859fn extract_method_range(
860 lines: &[&str],
861 outline: &[OutlineItem],
862 method_name: &str,
863) -> Option<(usize, usize)> {
864 let lower = method_name.to_lowercase();
865 for item in outline {
866 if matches!(item.kind.as_str(), "method" | "function")
867 && item.name.to_lowercase().starts_with(&lower)
868 {
869 let start_0 = item.line - 1;
870 let end_0 = detect_method_end(lines, start_0);
871 return Some((item.line, end_0 + 1));
872 }
873 }
874 None
875}
876
877fn source_roots_from_graph(g: &ServeGraph) -> Vec<PathBuf> {
878 let paths: Vec<PathBuf> = g
879 .nodes_iter()
880 .map(|(_, n)| PathBuf::from(&n.source_file))
881 .filter(|p| p.is_absolute())
882 .collect();
883 if paths.is_empty() {
884 return Vec::new();
885 }
886 let mut common = paths[0].clone();
887 for p in &paths[1..] {
888 while !p.starts_with(&common) {
889 common = match common.parent() {
890 Some(par) => par.to_path_buf(),
891 None => return vec![PathBuf::from("/")],
892 };
893 }
894 }
895 if common.as_os_str().is_empty() {
896 return Vec::new();
897 }
898 vec![common]
899}
900
901fn find_nodes_by_label<'a>(g: &'a ServeGraph, name: &str) -> Vec<(&'a str, &'a ServeNode)> {
902 let lower = name.to_lowercase();
903 let mut exact: Vec<(&str, &ServeNode)> = Vec::new();
904 let mut partial: Vec<(&str, &ServeNode)> = Vec::new();
905 for (id, node) in g.nodes_iter() {
906 let nl = node.label.to_lowercase();
907 if nl == lower {
908 exact.push((id, node));
909 } else if nl.contains(&lower) {
910 partial.push((id, node));
911 }
912 }
913 partial.sort_by_key(|(_, n)| n.label.len());
914 exact.extend(partial);
915 exact
916}
917
918fn search_source_files(
919 roots: &[PathBuf],
920 exts: &[&str],
921 pattern: &Regex,
922 max_hits: usize,
923) -> Vec<(String, usize, String)> {
924 let mut hits = Vec::new();
925 'outer: for root in roots {
926 for entry in WalkDir::new(root)
927 .follow_links(false)
928 .into_iter()
929 .filter_map(|e| e.ok())
930 {
931 if !entry.file_type().is_file() {
932 continue;
933 }
934 let ext = entry
935 .path()
936 .extension()
937 .and_then(|e| e.to_str())
938 .unwrap_or("");
939 if !exts.contains(&ext) {
940 continue;
941 }
942 if let Ok(content) = std::fs::read_to_string(entry.path()) {
943 let rel = entry
944 .path()
945 .strip_prefix(root)
946 .unwrap_or(entry.path())
947 .to_string_lossy()
948 .to_string();
949 for (lineno, line) in content.lines().enumerate() {
950 if pattern.is_match(line) {
951 let snippet: String = line.trim().chars().take(120).collect();
952 hits.push((rel.clone(), lineno + 1, snippet));
953 if hits.len() >= max_hits {
954 break 'outer;
955 }
956 }
957 }
958 }
959 }
960 }
961 hits
962}
963
964type SourceCacheMap = HashMap<PathBuf, (SystemTime, String, Vec<OutlineItem>)>;
969
970pub struct McpServer {
971 backend: Mutex<StoreBackend>,
972 graph_path: Option<PathBuf>,
973 last_mtime: Mutex<Option<SystemTime>>,
974 global_dir: PathBuf,
975 embedder: Option<StaticEmbedder>,
976 node_embeddings: HashMap<String, Vec<f32>>,
977 graph_cache: Mutex<HashMap<String, (SystemTime, Arc<ServeGraph>)>>,
978 pub(crate) source_cache: Mutex<SourceCacheMap>,
979 stale_check: Mutex<Option<bool>>,
980 telemetry: Arc<codesynapse_core::telemetry::Telemetry>,
981}
982
983impl McpServer {
984 pub fn new(graph_path: &Path) -> Result<Self> {
985 let global_dir = dirs::home_dir()
986 .unwrap_or_else(|| PathBuf::from("."))
987 .join(".codesynapse");
988 Self::new_with_global(graph_path, global_dir)
989 }
990
991 pub fn new_with_global(graph_path: &Path, global_dir: PathBuf) -> Result<Self> {
992 let store = SledGraphStore::open(graph_path)?;
993 let backend = StoreBackend::Sled(store);
994
995 let model_path = global_dir.join("models").join("potion-code-16M");
996 let (embedder, node_embeddings) = if model_path.exists() {
997 let emb_path = global_dir.join("embeddings.json");
998 if emb_path.exists() {
999 let embedder = StaticEmbedder::from_path(&model_path).ok();
1000 let embs: HashMap<String, Vec<f32>> = std::fs::read_to_string(&emb_path)
1001 .ok()
1002 .and_then(|t| serde_json::from_str(&t).ok())
1003 .unwrap_or_default();
1004 (embedder, embs)
1005 } else {
1006 (None, HashMap::new())
1007 }
1008 } else {
1009 (None, HashMap::new())
1010 };
1011
1012 let telemetry = Arc::new(codesynapse_core::telemetry::Telemetry::new(
1013 global_dir.clone(),
1014 ));
1015 Ok(McpServer {
1016 backend: Mutex::new(backend),
1017 graph_path: Some(graph_path.to_path_buf()),
1018 last_mtime: Mutex::new(None),
1019 global_dir,
1020 embedder,
1021 node_embeddings,
1022 graph_cache: Mutex::new(HashMap::new()),
1023 source_cache: Mutex::new(HashMap::new()),
1024 stale_check: Mutex::new(None),
1025 telemetry,
1026 })
1027 }
1028
1029 fn maybe_reload(&self) {
1030 let path = match &self.graph_path {
1031 Some(p) => p,
1032 None => return,
1033 };
1034 let mtime = match std::fs::metadata(path).and_then(|m| m.modified()) {
1035 Ok(t) => t,
1036 Err(_) => return,
1037 };
1038 let mut last = self.last_mtime.lock().unwrap();
1039 if last.is_none_or(|prev| prev != mtime) {
1040 if let Ok(store) = SledGraphStore::open(path) {
1041 *self.backend.lock().unwrap() = StoreBackend::Sled(store);
1042 }
1043 *last = Some(mtime);
1044 }
1045 }
1046
1047 pub fn run(&self) -> Result<()> {
1048 let stdin = io::stdin();
1049 let stdout = io::stdout();
1050 self.run_on(stdin.lock(), stdout.lock())
1051 }
1052
1053 pub fn run_on<R: BufRead, W: Write>(&self, reader: R, mut writer: W) -> Result<()> {
1054 self.telemetry.flush_bg();
1055 for line in reader.lines() {
1056 let line =
1057 line.map_err(|e| codesynapse_core::error::CodeSynapseError::msg(e.to_string()))?;
1058 if line.trim().is_empty() {
1059 continue;
1060 }
1061 let request: JsonRpcRequest = match serde_json::from_str(&line) {
1062 Ok(r) => r,
1063 Err(e) => {
1064 let resp = JsonRpcResponse {
1065 jsonrpc: "2.0".into(),
1066 id: None,
1067 result: None,
1068 error: Some(JsonRpcError {
1069 code: -32700,
1070 message: format!("Parse error: {}", e),
1071 data: None,
1072 }),
1073 };
1074 let mut out = serde_json::to_string(&resp)?;
1075 out.push('\n');
1076 writer.write_all(out.as_bytes())?;
1077 writer.flush()?;
1078 continue;
1079 }
1080 };
1081
1082 self.maybe_reload();
1083 let response = self.handle_request(&request);
1084 let mut out = serde_json::to_string(&response)?;
1085 out.push('\n');
1086 writer.write_all(out.as_bytes())?;
1087 writer.flush()?;
1088 }
1089
1090 self.telemetry.persist_sync();
1091 Ok(())
1092 }
1093
1094 fn handle_request(&self, request: &JsonRpcRequest) -> JsonRpcResponse {
1095 if request.method == "notifications/initialized" {
1096 return JsonRpcResponse {
1097 jsonrpc: "2.0".into(),
1098 id: None,
1099 result: None,
1100 error: None,
1101 };
1102 }
1103
1104 let result = match request.method.as_str() {
1105 "initialize" => Ok(serde_json::json!({
1106 "protocolVersion": "2024-11-05",
1107 "capabilities": { "tools": {} },
1108 "serverInfo": { "name": "codesynapse", "version": env!("CARGO_PKG_VERSION") },
1109 "instructions": CODESYNAPSE_INSTRUCTIONS
1110 })),
1111 "tools/list" => self.handle_tools_list(),
1112 "tools/call" => self.handle_tools_call(request),
1113 _ => Err(format!("Unknown method: {}", request.method)),
1114 };
1115
1116 match result {
1117 Ok(value) => JsonRpcResponse {
1118 jsonrpc: "2.0".into(),
1119 id: request.id.clone(),
1120 result: Some(value),
1121 error: None,
1122 },
1123 Err(msg) => JsonRpcResponse {
1124 jsonrpc: "2.0".into(),
1125 id: request.id.clone(),
1126 result: None,
1127 error: Some(JsonRpcError {
1128 code: -32603,
1129 message: msg,
1130 data: None,
1131 }),
1132 },
1133 }
1134 }
1135
1136 fn handle_tools_list(&self) -> std::result::Result<Value, String> {
1137 let tools: Vec<Value> = tool_defs()
1138 .into_iter()
1139 .map(|t| {
1140 serde_json::json!({
1141 "name": t.name,
1142 "description": t.description,
1143 "inputSchema": t.input_schema,
1144 })
1145 })
1146 .collect();
1147 Ok(serde_json::json!({ "tools": tools }))
1148 }
1149
1150 fn handle_tools_call(&self, request: &JsonRpcRequest) -> std::result::Result<Value, String> {
1151 let params = request
1152 .params
1153 .as_ref()
1154 .and_then(|p| p.as_object())
1155 .ok_or_else(|| "Missing params object".to_string())?;
1156
1157 let name = params
1158 .get("name")
1159 .and_then(|v| v.as_str())
1160 .ok_or_else(|| "Missing tool name".to_string())?;
1161
1162 let arguments = params
1163 .get("arguments")
1164 .and_then(|v| v.as_object())
1165 .cloned()
1166 .unwrap_or_default();
1167
1168 let text = match name {
1169 "codesynapse_query_graph" => self.tool_query_graph(&arguments),
1170 "codesynapse_get_node" => self.tool_get_node(&arguments),
1171 "codesynapse_get_neighbors" => self.tool_get_neighbors(&arguments),
1172 "codesynapse_get_community" => self.tool_get_community(&arguments),
1173 "codesynapse_god_nodes" => self.tool_god_nodes(&arguments),
1174 "codesynapse_graph_stats" => self.tool_graph_stats(&arguments),
1175 "codesynapse_shortest_path" => self.tool_shortest_path(&arguments),
1176 "codesynapse_find_all_paths" => self.tool_find_all_paths(&arguments),
1177 "codesynapse_weighted_path" => self.tool_weighted_path(&arguments),
1178 "codesynapse_community_bridges" => self.tool_community_bridges(&arguments),
1179 "codesynapse_diff" => self.tool_graph_diff(&arguments),
1180 "codesynapse_pagerank" => self.tool_pagerank(&arguments),
1181 "codesynapse_detect_cycles" => self.tool_detect_cycles(&arguments),
1182 "codesynapse_smart_summary" => self.tool_smart_summary(&arguments),
1183 "codesynapse_find_similar" => self.tool_find_similar(&arguments),
1184 "codesynapse_query_vector" => self.tool_graph_query_vector(&arguments),
1185 "codesynapse_blast_radius" => self.tool_graph_blast_radius(&arguments),
1186 "codesynapse_blast_radius_scored" => self.tool_graph_blast_radius_scored(&arguments),
1187 "codesynapse_blast_radius_multi" => self.tool_graph_blast_radius_multi(&arguments),
1188 "codesynapse_query_semantic" => self.tool_graph_query_semantic(&arguments),
1189 "codesynapse_hierarchy" => self.tool_graph_hierarchy(&arguments),
1190 "codesynapse_list_graphs" => self.tool_graph_list_graphs(&arguments),
1191 "codesynapse_module_summary" => self.tool_graph_module_summary(&arguments),
1192 "codesynapse_outline" => self.tool_codesynapse_outline(&arguments),
1193 "codesynapse_read" => self.tool_codesynapse_read(&arguments),
1194 "codesynapse_read_method" => self.tool_codesynapse_read_method(&arguments),
1195 "codesynapse_read_with_callees" => self.tool_codesynapse_read_with_callees(&arguments),
1196 "codesynapse_find_callers" => self.tool_codesynapse_find_callers(&arguments),
1197 "codesynapse_find_usages" => self.tool_codesynapse_find_usages(&arguments),
1198 "codesynapse_context" => self.tool_codesynapse_context(&arguments),
1199 "codesynapse_resolve" => self.tool_codesynapse_resolve(&arguments),
1200 "codesynapse_build" => self.tool_graph_build(&arguments),
1201 "codesynapse_stats" => self.tool_codesynapse_stats(&arguments),
1202 other => return Err(format!("Unknown tool: {}", other)),
1203 }?;
1204
1205 let text = if self.check_stale_once() {
1206 format!(
1207 "> ⚠ Graph may be stale — run `codesynapse build` or `codesynapse watch` to refresh.\n\n{text}"
1208 )
1209 } else {
1210 text
1211 };
1212
1213 Ok(serde_json::json!({
1214 "content": [{"type": "text", "text": text}]
1215 }))
1216 }
1217
1218 fn is_graph_stale(&self) -> bool {
1219 let global_graph = self.global_dir.join("global-graph.json");
1220 let Ok(gg_meta) = std::fs::metadata(&global_graph) else {
1221 return false;
1222 };
1223 let Ok(gg_mtime) = gg_meta.modified() else {
1224 return false;
1225 };
1226 let modules_dir = self.global_dir.join("modules");
1227 let Ok(entries) = std::fs::read_dir(&modules_dir) else {
1228 return false;
1229 };
1230 for entry in entries.flatten() {
1231 let graph_json = entry.path().join("graph.json");
1232 if let Ok(meta) = std::fs::metadata(&graph_json) {
1233 if let Ok(mtime) = meta.modified() {
1234 if mtime > gg_mtime {
1235 return true;
1236 }
1237 }
1238 }
1239 }
1240 false
1241 }
1242
1243 fn check_stale_once(&self) -> bool {
1244 let mut guard = self.stale_check.lock().unwrap();
1245 if let Some(v) = *guard {
1246 return v;
1247 }
1248 let stale = self.is_graph_stale();
1249 *guard = Some(stale);
1250 stale
1251 }
1252
1253 fn load_graph(&self) -> std::result::Result<(Vec<Node>, Vec<Edge>), String> {
1254 let backend = self.backend.lock().unwrap();
1255 let nodes = backend.get_all_nodes().map_err(|e| e.to_string())?;
1256 let edges = backend.get_all_edges().map_err(|e| e.to_string())?;
1257 Ok((nodes, edges))
1258 }
1259
1260 fn serve_graph_to_nodes_edges(g: &ServeGraph) -> (Vec<Node>, Vec<Edge>) {
1261 let nodes: Vec<Node> = g
1262 .nodes_iter()
1263 .map(|(id, n)| Node {
1264 id: id.to_string(),
1265 label: n.label.clone(),
1266 file_type: "code".to_string(),
1267 source_file: n.source_file.clone(),
1268 source_location: if n.source_location.is_empty() {
1269 None
1270 } else {
1271 Some(n.source_location.clone())
1272 },
1273 community: n.community.map(|c| c as usize),
1274 rationale: None,
1275 docstring: n.docstring.clone(),
1276 metadata: HashMap::new(),
1277 })
1278 .collect();
1279 let edges: Vec<Edge> = g
1280 .edges_iter()
1281 .map(|e: &ServeEdge| Edge {
1282 source: e.source.clone(),
1283 target: e.target.clone(),
1284 relation: e.relation.clone(),
1285 confidence: e.confidence.clone(),
1286 source_file: None,
1287 weight: 1.0,
1288 context: e.context.clone(),
1289 })
1290 .collect();
1291 (nodes, edges)
1292 }
1293
1294 fn load_graph_with_fallback(&self) -> std::result::Result<(Vec<Node>, Vec<Edge>), String> {
1295 let (nodes, edges) = self.load_graph()?;
1296 if nodes.is_empty() {
1297 let g = self.load_module_serve_graph("merged")?;
1298 return Ok(Self::serve_graph_to_nodes_edges(&g));
1299 }
1300 Ok((nodes, edges))
1301 }
1302
1303 fn str_arg<'a>(
1304 args: &'a Map<String, Value>,
1305 key: &str,
1306 ) -> std::result::Result<&'a str, String> {
1307 args.get(key)
1308 .and_then(|v| v.as_str())
1309 .ok_or_else(|| format!("Missing or invalid string argument: {}", key))
1310 }
1311
1312 fn int_arg(args: &Map<String, Value>, key: &str, default: i64) -> i64 {
1313 args.get(key).and_then(|v| v.as_i64()).unwrap_or(default)
1314 }
1315
1316 fn float_arg(args: &Map<String, Value>, key: &str, default: f64) -> f64 {
1317 args.get(key).and_then(|v| v.as_f64()).unwrap_or(default)
1318 }
1319
1320 fn tool_query_graph(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1321 let question = Self::str_arg(args, "question")?;
1322 let budget = Self::int_arg(args, "budget", 5) as usize;
1323 let (_nodes, edges) = self.load_graph_with_fallback()?;
1324 let keywords: Vec<&str> = question.split_whitespace().collect();
1326 let matched: Vec<String> = edges
1327 .iter()
1328 .filter(|e| {
1329 keywords
1330 .iter()
1331 .any(|k| e.source.contains(k) || e.target.contains(k) || e.relation.contains(k))
1332 })
1333 .take(budget)
1334 .map(|e| format!("{} --[{}]--> {}", e.source, e.relation, e.target))
1335 .collect();
1336 if matched.is_empty() {
1337 Ok("No matching results found.".to_string())
1338 } else {
1339 Ok(matched.join("\n"))
1340 }
1341 }
1342
1343 fn tool_get_node(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1344 let node_id = Self::str_arg(args, "node_id")?;
1345 let sled_result = self
1347 .backend
1348 .lock()
1349 .unwrap()
1350 .get_node(node_id)
1351 .ok()
1352 .flatten();
1353 if let Some(n) = sled_result {
1354 return serde_json::to_string_pretty(&n).map_err(|e| e.to_string());
1355 }
1356 match self.load_module_serve_graph("merged") {
1357 Ok(g) => match g.get_node(node_id) {
1358 Some(n) => Ok(format!(
1359 "id: {}\nlabel: {}\nsource_file: {}\nsource_location: {}",
1360 n.id, n.label, n.source_file, n.source_location
1361 )),
1362 None => Ok(format!("Node '{}' not found", node_id)),
1363 },
1364 Err(_) => Ok(format!("Node '{}' not found", node_id)),
1365 }
1366 }
1367
1368 fn tool_get_neighbors(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1369 let node_id = Self::str_arg(args, "node_id")?;
1370 let depth = Self::int_arg(args, "depth", 1) as usize;
1371 let limit = Self::int_arg(args, "limit", 50) as usize;
1372 let offset = Self::int_arg(args, "offset", 0) as usize;
1373
1374 let sled_empty = self.backend.lock().unwrap().node_count().unwrap_or(0) == 0;
1376 if !sled_empty {
1377 let mut collected = Vec::new();
1378 let mut current = vec![node_id.to_string()];
1379 let mut visited = std::collections::HashSet::new();
1380 visited.insert(node_id.to_string());
1381 for _ in 0..depth {
1382 let mut next = Vec::new();
1383 for id in ¤t {
1384 if let Ok(neighbors) = self.backend.lock().unwrap().neighbors(id, None) {
1385 for (neighbor, edge) in &neighbors {
1386 if visited.insert(neighbor.id.clone()) {
1387 collected.push(format!(
1388 "{} --[{}]--> {}",
1389 edge.source, edge.relation, edge.target
1390 ));
1391 next.push(neighbor.id.clone());
1392 }
1393 }
1394 }
1395 }
1396 current = next;
1397 }
1398 let total = collected.len();
1399 let slice: Vec<String> = collected.into_iter().skip(offset).take(limit).collect();
1400 return Ok(format!(
1401 "Found {} neighbors (showing {}):\n{}",
1402 total,
1403 slice.len(),
1404 slice.join("\n")
1405 ));
1406 }
1407
1408 let (_nodes, edges) = self.load_graph_with_fallback()?;
1410 let mut collected = Vec::new();
1411 let mut current = vec![node_id.to_string()];
1412 let mut visited = std::collections::HashSet::new();
1413 visited.insert(node_id.to_string());
1414 for _ in 0..depth {
1415 let mut next = Vec::new();
1416 for id in ¤t {
1417 for e in edges.iter().filter(|e| e.source == *id || e.target == *id) {
1418 let neighbor = if e.source == *id {
1419 &e.target
1420 } else {
1421 &e.source
1422 };
1423 if visited.insert(neighbor.clone()) {
1424 collected.push(format!("{} --[{}]--> {}", e.source, e.relation, e.target));
1425 next.push(neighbor.clone());
1426 }
1427 }
1428 }
1429 current = next;
1430 }
1431 let total = collected.len();
1432 let slice: Vec<String> = collected.into_iter().skip(offset).take(limit).collect();
1433 Ok(format!(
1434 "Found {} neighbors (showing {}):\n{}",
1435 total,
1436 slice.len(),
1437 slice.join("\n")
1438 ))
1439 }
1440
1441 fn tool_get_community(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1442 let community_id = Self::int_arg(args, "community_id", 0) as usize;
1443 let limit = Self::int_arg(args, "limit", 50) as usize;
1444 let offset = Self::int_arg(args, "offset", 0) as usize;
1445
1446 let (nodes, _edges) = self.load_graph_with_fallback()?;
1447 let community_nodes: Vec<&Node> = nodes
1448 .iter()
1449 .filter(|n| n.community == Some(community_id))
1450 .skip(offset)
1451 .take(limit)
1452 .collect();
1453
1454 if community_nodes.is_empty() {
1455 return Ok(format!("No nodes found in community {}", community_id));
1456 }
1457
1458 let lines: Vec<String> = community_nodes
1459 .iter()
1460 .map(|n| format!("{} ({})", n.label, n.id))
1461 .collect();
1462 Ok(format!(
1463 "Community {} ({} nodes):\n{}",
1464 community_id,
1465 community_nodes.len(),
1466 lines.join("\n")
1467 ))
1468 }
1469
1470 fn tool_god_nodes(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1471 let top_n = Self::int_arg(args, "top_n", 10) as usize;
1472 let (nodes, edges) = self.load_graph_with_fallback()?;
1473 let analyzer = Analyzer;
1474 let gods = analyzer.god_nodes(&nodes, &edges, top_n);
1475 let lines: Vec<String> = gods
1476 .iter()
1477 .map(|n| format!("{} ({})", n.label, n.id))
1478 .collect();
1479 Ok(format!("Top {} god nodes:\n{}", top_n, lines.join("\n")))
1480 }
1481
1482 fn tool_graph_stats(&self, _args: &Map<String, Value>) -> std::result::Result<String, String> {
1483 let (nodes, edges) = self.load_graph_with_fallback()?;
1484 Ok(format!("Nodes: {}\nEdges: {}", nodes.len(), edges.len()))
1485 }
1486
1487 fn tool_shortest_path(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1488 let source = Self::str_arg(args, "source")?;
1489 let target = Self::str_arg(args, "target")?;
1490 let path = self
1491 .backend
1492 .lock()
1493 .unwrap()
1494 .shortest_path(source, target)
1495 .map_err(|e| e.to_string())?;
1496 match path {
1497 Some(nodes) => {
1498 let labels: Vec<String> = nodes.iter().map(|n| n.label.clone()).collect();
1499 Ok(format!(
1500 "Shortest path ({} hops): {}",
1501 labels.len() - 1,
1502 labels.join(" -> ")
1503 ))
1504 }
1505 None => Ok(format!(
1506 "No path found between '{}' and '{}'",
1507 source, target
1508 )),
1509 }
1510 }
1511
1512 fn tool_find_all_paths(
1513 &self,
1514 args: &Map<String, Value>,
1515 ) -> std::result::Result<String, String> {
1516 let source = Self::str_arg(args, "source")?.to_string();
1517 let target = Self::str_arg(args, "target")?.to_string();
1518 let max_length = Self::int_arg(args, "max_length", 5) as usize;
1519
1520 let (_nodes, edges) = self.load_graph_with_fallback()?;
1521 let adj: HashMap<&str, Vec<&str>> = {
1522 let mut m: HashMap<&str, Vec<&str>> = HashMap::new();
1523 for e in &edges {
1524 m.entry(e.source.as_str())
1525 .or_default()
1526 .push(e.target.as_str());
1527 }
1528 m
1529 };
1530
1531 let mut all_paths: Vec<Vec<String>> = Vec::new();
1532 let mut stack: Vec<(Vec<&str>, std::collections::HashSet<&str>)> = Vec::new();
1534 let mut init_visited = std::collections::HashSet::new();
1535 init_visited.insert(source.as_str());
1536 stack.push((vec![source.as_str()], init_visited));
1537
1538 while let Some((current, visited)) = stack.pop() {
1539 if current.len() > max_length {
1540 continue;
1541 }
1542 let node = *current.last().unwrap();
1543 if node == target.as_str() {
1544 all_paths.push(current.iter().map(|s| s.to_string()).collect());
1545 continue;
1546 }
1547 if let Some(neighbors) = adj.get(node) {
1548 for &next in neighbors {
1549 if !visited.contains(next) {
1550 let mut new_path = current.clone();
1551 new_path.push(next);
1552 let mut new_visited = visited.clone();
1553 new_visited.insert(next);
1554 stack.push((new_path, new_visited));
1555 }
1556 }
1557 }
1558 }
1559
1560 if all_paths.is_empty() {
1561 return Ok(format!(
1562 "No paths found between '{}' and '{}'",
1563 source, target
1564 ));
1565 }
1566
1567 let lines: Vec<String> = all_paths
1568 .iter()
1569 .enumerate()
1570 .map(|(i, p)| format!(" {}. {}", i + 1, p.join(" -> ")))
1571 .collect();
1572 Ok(format!(
1573 "Found {} paths:\n{}",
1574 all_paths.len(),
1575 lines.join("\n")
1576 ))
1577 }
1578
1579 fn tool_weighted_path(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1580 let source = Self::str_arg(args, "source")?;
1581 let target = Self::str_arg(args, "target")?;
1582 let _min_confidence = Self::float_arg(args, "min_confidence", 0.0);
1583 let path = self
1584 .backend
1585 .lock()
1586 .unwrap()
1587 .dijkstra_shortest_path(source, target)
1588 .map_err(|e| e.to_string())?;
1589 match path {
1590 Some(nodes) => {
1591 let labels: Vec<String> = nodes.iter().map(|n| n.label.clone()).collect();
1592 Ok(format!(
1593 "Weighted path ({} hops): {}",
1594 labels.len() - 1,
1595 labels.join(" -> ")
1596 ))
1597 }
1598 None => Ok(format!(
1599 "No path found between '{}' and '{}'",
1600 source, target
1601 )),
1602 }
1603 }
1604
1605 fn tool_community_bridges(
1606 &self,
1607 args: &Map<String, Value>,
1608 ) -> std::result::Result<String, String> {
1609 let top_n = Self::int_arg(args, "top_n", 10) as usize;
1610 let (nodes, edges) = self.load_graph_with_fallback()?;
1611 let analyzer = Analyzer;
1612 let bridges = analyzer.bridge_edges(&nodes, &edges);
1613 let top: Vec<&Edge> = bridges.iter().take(top_n).collect();
1614 if top.is_empty() {
1615 return Ok("No bridge edges found.".to_string());
1616 }
1617 let lines: Vec<String> = top
1618 .iter()
1619 .map(|e| format!("{} --[{}]--> {}", e.source, e.relation, e.target))
1620 .collect();
1621 Ok(format!(
1622 "Top {} bridge edges:\n{}",
1623 top.len(),
1624 lines.join("\n")
1625 ))
1626 }
1627
1628 fn tool_graph_diff(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1629 let other_path = Self::str_arg(args, "other_graph")?;
1630 let other_store = SledGraphStore::open(Path::new(other_path)).map_err(|e| e.to_string())?;
1631 let other_backend = StoreBackend::Sled(other_store);
1632 let other_edges = other_backend.get_all_edges().map_err(|e| e.to_string())?;
1633 let (_nodes, edges) = self.load_graph_with_fallback()?;
1634 let analyzer = Analyzer;
1635 let (added, removed) = analyzer.graph_diff(&other_edges, &edges);
1636 Ok(format!(
1637 "Added edges: {}\nRemoved edges: {}",
1638 added.len(),
1639 removed.len()
1640 ))
1641 }
1642
1643 fn tool_pagerank(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1644 let top_n = Self::int_arg(args, "top_n", 10) as usize;
1645 let damping = Self::float_arg(args, "damping", 0.85);
1646 let max_iter = Self::int_arg(args, "max_iter", 100) as usize;
1647 let (_nodes, edges) = self.load_graph_with_fallback()?;
1648 let analyzer = Analyzer;
1649 let mut ranks = analyzer.pagerank(&edges, damping, max_iter);
1650 let mut sorted: Vec<(String, f64)> = ranks.drain().collect();
1651 sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1652 let top: Vec<String> = sorted
1653 .iter()
1654 .take(top_n)
1655 .map(|(id, score)| format!("{}: {:.6}", id, score))
1656 .collect();
1657 Ok(format!("Top {} PageRank:\n{}", top_n, top.join("\n")))
1658 }
1659
1660 fn tool_detect_cycles(
1661 &self,
1662 _args: &Map<String, Value>,
1663 ) -> std::result::Result<String, String> {
1664 let (_nodes, edges) = self.load_graph_with_fallback()?;
1665 let analyzer = Analyzer;
1666 let sccs = analyzer.tarjan_scc(&edges);
1667 if sccs.is_empty() {
1668 return Ok("No cycles detected.".to_string());
1669 }
1670 let lines: Vec<String> = sccs
1671 .iter()
1672 .filter(|scc| scc.len() > 1)
1673 .enumerate()
1674 .map(|(i, scc)| format!(" Cycle {}: {}", i + 1, scc.join(" -> ")))
1675 .collect();
1676 if lines.is_empty() {
1677 return Ok("No cycles detected (all SCCs are singletons).".to_string());
1678 }
1679 Ok(format!(
1680 "Detected {} cycles:\n{}",
1681 lines.len(),
1682 lines.join("\n")
1683 ))
1684 }
1685
1686 fn tool_smart_summary(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1687 let level = args
1688 .get("level")
1689 .and_then(|v| v.as_str())
1690 .unwrap_or("architecture");
1691 let budget = Self::int_arg(args, "budget", 100) as usize;
1692 let (nodes, edges) = self.load_graph_with_fallback()?;
1693
1694 match level {
1695 "detailed" => {
1696 let mut lines = vec![format!(
1697 "Detailed summary ({} nodes, {} edges, max {} items):",
1698 nodes.len(),
1699 edges.len(),
1700 budget
1701 )];
1702 for node in nodes.iter().take(budget / 2) {
1703 lines.push(format!(" Node: {} ({})", node.label, node.id));
1704 }
1705 for edge in edges.iter().take(budget / 2) {
1706 lines.push(format!(
1707 " Edge: {} --[{}]--> {}",
1708 edge.source, edge.relation, edge.target
1709 ));
1710 }
1711 Ok(lines.join("\n"))
1712 }
1713 "community" => {
1714 let mut community_map: HashMap<Option<usize>, Vec<&Node>> = HashMap::new();
1715 for node in &nodes {
1716 community_map.entry(node.community).or_default().push(node);
1717 }
1718 let mut lines = vec![format!(
1719 "Community summary ({} communities):",
1720 community_map.len()
1721 )];
1722 for (id, members) in community_map.iter().take(budget) {
1723 lines.push(format!(" Community {:?}: {} members", id, members.len()));
1724 }
1725 Ok(lines.join("\n"))
1726 }
1727 _ => {
1728 let node_count = nodes.len();
1730 let edge_count = edges.len();
1731 let analyzer = Analyzer;
1732 let gods = analyzer.god_nodes(&nodes, &edges, 5);
1733 let sccs = analyzer.tarjan_scc(&edges);
1734 let has_cycles = sccs.iter().any(|scc| scc.len() > 1);
1735 let god_lines: Vec<String> = gods.iter().map(|n| n.label.clone()).collect();
1736 Ok(format!(
1737 "Architecture Summary:\n\
1738 - Nodes: {}\n\
1739 - Edges: {}\n\
1740 - Cycles: {}\n\
1741 - Top nodes: {}\n\
1742 - Average degree: {:.2}",
1743 node_count,
1744 edge_count,
1745 if has_cycles { "yes" } else { "no" },
1746 god_lines.join(", "),
1747 if node_count > 0 {
1748 edge_count as f64 / node_count as f64
1749 } else {
1750 0.0
1751 }
1752 ))
1753 }
1754 }
1755 }
1756
1757 fn tool_find_similar(&self, args: &Map<String, Value>) -> std::result::Result<String, String> {
1758 let node_id = Self::str_arg(args, "node_id")?;
1759 let top_n = Self::int_arg(args, "top_n", 10) as usize;
1760
1761 let (_nodes, edges) = self.load_graph_with_fallback()?;
1762 let analyzer = Analyzer;
1763 let similar = analyzer.find_similar(&edges, node_id, top_n);
1764
1765 if similar.is_empty() {
1766 Ok(format!("No similar nodes found for '{}'", node_id))
1767 } else {
1768 let lines: Vec<String> = similar
1769 .iter()
1770 .map(|(id, score)| format!(" {} (similarity: {:.4})", id, score))
1771 .collect();
1772 Ok(format!(
1773 "Top {} nodes similar to '{}':\n{}",
1774 similar.len(),
1775 node_id,
1776 lines.join("\n")
1777 ))
1778 }
1779 }
1780
1781 fn load_module_serve_graph(
1782 &self,
1783 module: &str,
1784 ) -> std::result::Result<Arc<ServeGraph>, String> {
1785 let path = if module == "merged" || module.is_empty() {
1786 let p = self.global_dir.join("global-graph.json");
1787 if !p.exists() {
1788 return Err("No merged graph found. Run `codesynapse global add <path>` to register graphs.".into());
1789 }
1790 p
1791 } else {
1792 let repos = global_list(&self.global_dir);
1793 let entry = repos.get(module).ok_or_else(|| {
1794 format!(
1795 "Module '{}' not found. Use graph_list_graphs to see available modules.",
1796 module
1797 )
1798 })?;
1799 PathBuf::from(&entry.source_path)
1800 };
1801
1802 let mtime = std::fs::metadata(&path)
1803 .and_then(|m| m.modified())
1804 .unwrap_or(SystemTime::UNIX_EPOCH);
1805
1806 {
1807 let cache = self.graph_cache.lock().unwrap();
1808 if let Some((cached_mtime, arc)) = cache.get(module) {
1809 if *cached_mtime == mtime {
1810 return Ok(Arc::clone(arc));
1811 }
1812 }
1813 }
1814
1815 let g = load_graph(&path).map_err(|e| e.to_string())?;
1816 let arc = Arc::new(g);
1817 self.graph_cache
1818 .lock()
1819 .unwrap()
1820 .insert(module.to_string(), (mtime, Arc::clone(&arc)));
1821 Ok(arc)
1822 }
1823
1824 fn tool_graph_list_graphs(
1825 &self,
1826 _args: &Map<String, Value>,
1827 ) -> std::result::Result<String, String> {
1828 let repos = global_list(&self.global_dir);
1829 if repos.is_empty() {
1830 return Ok("No graphs registered. Use `codesynapse global add <path> --as <tag>` to register one.".into());
1831 }
1832 let mut lines = vec![
1833 format!(
1834 "{:<30} {:>7} {:>7} {}",
1835 "Module", "Nodes", "Edges", "Source"
1836 ),
1837 format!("{}", "─".repeat(80)),
1838 ];
1839 let mut sorted: Vec<_> = repos.iter().collect();
1840 sorted.sort_by_key(|(k, _)| k.as_str());
1841 for (tag, entry) in sorted {
1842 lines.push(format!(
1843 "{:<30} {:>7} {:>7} {}",
1844 tag, entry.node_count, entry.edge_count, entry.source_path
1845 ));
1846 }
1847 Ok(lines.join("\n"))
1848 }
1849
1850 fn tool_graph_module_summary(
1851 &self,
1852 args: &Map<String, Value>,
1853 ) -> std::result::Result<String, String> {
1854 let module = Self::str_arg(args, "module")?;
1855 let repos = global_list(&self.global_dir);
1856 let entry = repos
1857 .get(module)
1858 .ok_or_else(|| format!("Module '{}' not found.", module))?;
1859
1860 let text = std::fs::read_to_string(&entry.source_path)
1861 .map_err(|e| format!("Could not read graph: {}", e))?;
1862 let v: serde_json::Value =
1863 serde_json::from_str(&text).map_err(|e| format!("Bad graph JSON: {}", e))?;
1864
1865 let nodes: Vec<Node> = v
1866 .get("nodes")
1867 .and_then(|n| n.as_array())
1868 .map(|arr| {
1869 arr.iter()
1870 .filter_map(|i| serde_json::from_value(i.clone()).ok())
1871 .collect()
1872 })
1873 .unwrap_or_default();
1874 let edges: Vec<Edge> = v
1875 .get("edges")
1876 .or_else(|| v.get("links"))
1877 .and_then(|e| e.as_array())
1878 .map(|arr| {
1879 arr.iter()
1880 .filter_map(|i| serde_json::from_value(i.clone()).ok())
1881 .collect()
1882 })
1883 .unwrap_or_default();
1884
1885 let analyzer = Analyzer;
1886 let gods = analyzer.god_nodes(&nodes, &edges, 5);
1887 let god_labels: Vec<String> = gods.iter().map(|n| n.label.clone()).collect();
1888
1889 let mut lang_counts: HashMap<&str, usize> = HashMap::new();
1890 for n in &nodes {
1891 *lang_counts.entry(n.file_type.as_str()).or_default() += 1;
1892 }
1893 let mut lang_sorted: Vec<_> = lang_counts.iter().collect();
1894 lang_sorted.sort_by(|a, b| b.1.cmp(a.1));
1895 let lang_str: Vec<String> = lang_sorted
1896 .iter()
1897 .take(5)
1898 .map(|(k, v)| format!("{}: {}", k, v))
1899 .collect();
1900
1901 Ok(format!(
1902 "Module: {}\nNodes: {}\nEdges: {}\nTop nodes: {}\nLanguages: {}\nSource: {}",
1903 module,
1904 entry.node_count,
1905 entry.edge_count,
1906 if god_labels.is_empty() { "(none)".to_string() } else { god_labels.join(", ") },
1907 if lang_str.is_empty() { "(none)".to_string() } else { lang_str.join(", ") },
1908 entry.source_path,
1909 ))
1910 }
1911
1912 fn tool_graph_blast_radius(
1913 &self,
1914 args: &Map<String, Value>,
1915 ) -> std::result::Result<String, String> {
1916 let class_name = Self::str_arg(args, "class_name")?;
1917 let graph_name = args
1918 .get("graph")
1919 .and_then(|v| v.as_str())
1920 .unwrap_or("merged");
1921 let depth = Self::int_arg(args, "depth", 3) as usize;
1922
1923 let g = self.load_module_serve_graph(graph_name)?;
1924
1925 let seed_id = g
1926 .nodes_iter()
1927 .find(|(id, n)| n.label.eq_ignore_ascii_case(class_name) || id.contains(class_name))
1928 .map(|(id, _)| id.to_string())
1929 .ok_or_else(|| format!("Node '{}' not found in graph '{}'.", class_name, graph_name))?;
1930
1931 let mut by_depth: Vec<Vec<String>> = vec![vec![seed_id.clone()]];
1932 let mut visited = std::collections::HashSet::new();
1933 visited.insert(seed_id.clone());
1934
1935 for d in 1..=depth {
1936 let prev = by_depth[d - 1].clone();
1937 let mut next = Vec::new();
1938 for node_id in &prev {
1939 for neighbor_id in g.neighbors(node_id) {
1940 if visited.insert(neighbor_id.clone()) {
1941 next.push(neighbor_id.clone());
1942 }
1943 }
1944 }
1945 if next.is_empty() {
1946 break;
1947 }
1948 by_depth.push(next);
1949 }
1950
1951 let mut lines = vec![format!(
1952 "Blast radius of '{}' in '{}' (max depth {})",
1953 class_name, graph_name, depth
1954 )];
1955 for (d, nodes) in by_depth.iter().enumerate() {
1956 let labels: Vec<String> = nodes
1957 .iter()
1958 .map(|id| {
1959 g.get_node(id)
1960 .map(|n| n.label.clone())
1961 .unwrap_or_else(|| id.clone())
1962 })
1963 .collect();
1964 if d == 0 {
1965 lines.push(format!(" Origin: {}", labels.join(", ")));
1966 } else {
1967 lines.push(format!(" Depth {}: {}", d, labels.join(", ")));
1968 }
1969 }
1970 lines.push(format!("Total affected: {}", visited.len() - 1));
1971 Ok(lines.join("\n"))
1972 }
1973
1974 fn risk_score_node(g: &ServeGraph, node_id: &str) -> (f32, Vec<String>) {
1975 const KEYWORDS: &[&str] = &[
1976 "auth",
1977 "pay",
1978 "password",
1979 "token",
1980 "secret",
1981 "credit",
1982 "billing",
1983 "admin",
1984 "security",
1985 "encrypt",
1986 "hash",
1987 "key",
1988 "credential",
1989 ];
1990 let node = g.get_node(node_id);
1991 let label_lower = node.map(|n| n.label.to_lowercase()).unwrap_or_default();
1992 let file_lower = node
1993 .map(|n| n.source_file.to_lowercase())
1994 .unwrap_or_default();
1995 let combined = format!("{} {}", label_lower, file_lower);
1996
1997 let mut score = 0.0f32;
1998 let mut reasons = Vec::new();
1999
2000 let kw_hits: Vec<&str> = KEYWORDS
2001 .iter()
2002 .copied()
2003 .filter(|k| combined.contains(k))
2004 .collect();
2005 if !kw_hits.is_empty() {
2006 let kw_score = (kw_hits.len() as f32 * 0.15).min(0.4);
2007 score += kw_score;
2008 reasons.push(format!("keywords: {}", kw_hits.join(",")));
2009 }
2010
2011 let in_degree = g.edges_iter().filter(|e| e.target == node_id).count();
2012 if in_degree >= 10 {
2013 score += 0.3;
2014 reasons.push(format!("in_degree={}", in_degree));
2015 } else if in_degree >= 5 {
2016 score += 0.15;
2017 reasons.push(format!("in_degree={}", in_degree));
2018 }
2019
2020 let has_test_neighbor = g.edges_iter().any(|e| {
2021 if e.source == node_id || e.target == node_id {
2022 let neighbor_id = if e.source == node_id {
2023 &e.target
2024 } else {
2025 &e.source
2026 };
2027 g.get_node(neighbor_id)
2028 .map(|n| {
2029 let f = &n.source_file;
2030 f.contains("test")
2031 || f.contains("spec")
2032 || f.contains("Test")
2033 || f.contains("_test")
2034 })
2035 .unwrap_or(false)
2036 } else {
2037 false
2038 }
2039 });
2040 if !has_test_neighbor {
2041 score += 0.2;
2042 reasons.push("no_test_coverage".into());
2043 }
2044
2045 (score.min(1.0), reasons)
2046 }
2047
2048 fn tool_graph_blast_radius_scored(
2049 &self,
2050 args: &Map<String, Value>,
2051 ) -> std::result::Result<String, String> {
2052 let class_name = Self::str_arg(args, "class_name")?;
2053 let graph_name = args
2054 .get("graph")
2055 .and_then(|v| v.as_str())
2056 .unwrap_or("merged");
2057 let depth = Self::int_arg(args, "depth", 3) as usize;
2058
2059 let g = self.load_module_serve_graph(graph_name)?;
2060
2061 let seed_id = g
2062 .nodes_iter()
2063 .find(|(id, n)| n.label.eq_ignore_ascii_case(class_name) || id.contains(class_name))
2064 .map(|(id, _)| id.to_string())
2065 .ok_or_else(|| format!("Node '{}' not found in graph '{}'.", class_name, graph_name))?;
2066
2067 let mut by_depth: Vec<Vec<String>> = vec![vec![seed_id.clone()]];
2068 let mut visited = std::collections::HashSet::new();
2069 visited.insert(seed_id.clone());
2070
2071 for d in 1..=depth {
2072 let prev = by_depth[d - 1].clone();
2073 let mut next = Vec::new();
2074 for node_id in &prev {
2075 for neighbor_id in g.neighbors(node_id) {
2076 if visited.insert(neighbor_id.clone()) {
2077 next.push(neighbor_id.clone());
2078 }
2079 }
2080 }
2081 if next.is_empty() {
2082 break;
2083 }
2084 by_depth.push(next);
2085 }
2086
2087 let affected: Vec<String> = visited
2088 .iter()
2089 .filter(|id| *id != &seed_id)
2090 .cloned()
2091 .collect();
2092
2093 let mut scored: Vec<(String, f32, Vec<String>)> = affected
2094 .iter()
2095 .map(|id| {
2096 let label = g
2097 .get_node(id)
2098 .map(|n| n.label.clone())
2099 .unwrap_or_else(|| id.clone());
2100 let (score, reasons) = Self::risk_score_node(&g, id);
2101 (label, score, reasons)
2102 })
2103 .collect();
2104 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2105
2106 let mut lines = vec![format!(
2107 "Blast radius of '{}' in '{}' (max depth {}) — risk scored",
2108 class_name, graph_name, depth
2109 )];
2110 let tier = |s: f32| {
2111 if s >= 0.6 {
2112 "HIGH"
2113 } else if s >= 0.3 {
2114 "MEDIUM"
2115 } else {
2116 "LOW"
2117 }
2118 };
2119 for (label, score, reasons) in &scored {
2120 let reason_str = if reasons.is_empty() {
2121 String::new()
2122 } else {
2123 format!(" [{}]", reasons.join(", "))
2124 };
2125 lines.push(format!(
2126 " [{:.2} {}] {}{}",
2127 score,
2128 tier(*score),
2129 label,
2130 reason_str
2131 ));
2132 }
2133 lines.push(format!("Total affected: {}", affected.len()));
2134 Ok(lines.join("\n"))
2135 }
2136
2137 fn tool_graph_blast_radius_multi(
2138 &self,
2139 args: &Map<String, Value>,
2140 ) -> std::result::Result<String, String> {
2141 let graph_name = args
2142 .get("graph")
2143 .and_then(|v| v.as_str())
2144 .unwrap_or("merged");
2145 let depth = Self::int_arg(args, "depth", 3) as usize;
2146
2147 let names_val = args
2148 .get("class_names")
2149 .and_then(|v| v.as_array())
2150 .ok_or_else(|| "Missing required argument: class_names".to_string())?;
2151 let class_names: Vec<String> = names_val
2152 .iter()
2153 .filter_map(|v| v.as_str().map(str::to_string))
2154 .collect();
2155
2156 if class_names.is_empty() {
2157 return Err("class_names must be a non-empty array".to_string());
2158 }
2159
2160 let g = self.load_module_serve_graph(graph_name)?;
2161
2162 let mut seed_ids: Vec<String> = Vec::new();
2163 let mut not_found: Vec<String> = Vec::new();
2164 for name in &class_names {
2165 match g
2166 .nodes_iter()
2167 .find(|(id, n)| n.label.eq_ignore_ascii_case(name) || id.contains(name.as_str()))
2168 {
2169 Some((id, _)) => seed_ids.push(id.to_string()),
2170 None => not_found.push(name.clone()),
2171 }
2172 }
2173
2174 let mut by_depth: Vec<Vec<String>> = vec![seed_ids.clone()];
2175 let mut visited: std::collections::HashSet<String> = seed_ids.iter().cloned().collect();
2176
2177 for d in 1..=depth {
2178 let prev = by_depth[d - 1].clone();
2179 let mut next = Vec::new();
2180 for node_id in &prev {
2181 for neighbor_id in g.neighbors(node_id) {
2182 if visited.insert(neighbor_id.clone()) {
2183 next.push(neighbor_id.clone());
2184 }
2185 }
2186 }
2187 if next.is_empty() {
2188 break;
2189 }
2190 by_depth.push(next);
2191 }
2192
2193 let seed_set: std::collections::HashSet<&String> = seed_ids.iter().collect();
2194 let total_affected = visited.iter().filter(|id| !seed_set.contains(*id)).count();
2195
2196 let mut lines = vec![format!(
2197 "Multi blast radius in '{}' (seeds: {}, max depth {})",
2198 graph_name,
2199 class_names.join(", "),
2200 depth
2201 )];
2202 if !not_found.is_empty() {
2203 lines.push(format!("Not found: {}", not_found.join(", ")));
2204 }
2205 for (d, nodes) in by_depth.iter().enumerate() {
2206 let labels: Vec<String> = nodes
2207 .iter()
2208 .map(|id| {
2209 g.get_node(id)
2210 .map(|n| n.label.clone())
2211 .unwrap_or_else(|| id.clone())
2212 })
2213 .collect();
2214 if d == 0 {
2215 lines.push(format!(" Seeds: {}", labels.join(", ")));
2216 } else {
2217 lines.push(format!(" Depth {}: {}", d, labels.join(", ")));
2218 }
2219 }
2220 lines.push(format!("Total affected: {}", total_affected));
2221 Ok(lines.join("\n"))
2222 }
2223
2224 fn tool_graph_query_semantic(
2225 &self,
2226 args: &Map<String, Value>,
2227 ) -> std::result::Result<String, String> {
2228 let query = Self::str_arg(args, "query")?;
2229 let graph_name = args
2230 .get("graph")
2231 .and_then(|v| v.as_str())
2232 .unwrap_or("merged");
2233 let depth = Self::int_arg(args, "depth", 2) as usize;
2234 let min_confidence = args
2235 .get("min_confidence")
2236 .and_then(|v| v.as_f64())
2237 .unwrap_or(0.7) as f32;
2238
2239 let g = self.load_module_serve_graph(graph_name)?;
2240
2241 let query_lower = query.to_lowercase();
2242 let seed_ids: Vec<String> = {
2243 let mut seeds: Vec<String> = g
2244 .nodes_iter()
2245 .filter(|(_, n)| {
2246 let l = n.label.to_lowercase();
2247 query_lower.split_whitespace().any(|w| l.contains(w))
2248 })
2249 .take(5)
2250 .map(|(id, _)| id.to_string())
2251 .collect();
2252 if seeds.is_empty() {
2253 seeds = find_nodes_by_label(&g, query)
2254 .into_iter()
2255 .take(5)
2256 .map(|(id, _)| id.to_string())
2257 .collect();
2258 }
2259 seeds
2260 };
2261
2262 if seed_ids.is_empty() {
2263 return Ok(format!("No seed nodes found for query '{}'.", query));
2264 }
2265
2266 let has_any_semantic = g
2267 .edges_iter()
2268 .any(|e| e.relation == "semantically_similar_to");
2269 if !has_any_semantic {
2270 return Ok(format!(
2271 "No semantic neighbors found for '{}'. Graph may not have been built with --llm.",
2272 query
2273 ));
2274 }
2275
2276 let mut visited: std::collections::HashSet<String> = seed_ids.iter().cloned().collect();
2277 let mut frontier: Vec<String> = seed_ids.clone();
2278 let mut results: Vec<(String, usize)> = Vec::new();
2279
2280 for d in 1..=depth {
2281 let mut next_frontier = Vec::new();
2282 for node_id in &frontier {
2283 for edge in g.edges_iter() {
2284 if edge.relation != "semantically_similar_to" {
2285 continue;
2286 }
2287 let conf: f32 = edge.confidence.parse().unwrap_or(1.0);
2288 if conf < min_confidence {
2289 continue;
2290 }
2291 let neighbor = if edge.source == *node_id {
2292 Some(edge.target.clone())
2293 } else if edge.target == *node_id {
2294 Some(edge.source.clone())
2295 } else {
2296 None
2297 };
2298 if let Some(nb) = neighbor {
2299 if visited.insert(nb.clone()) {
2300 next_frontier.push(nb.clone());
2301 results.push((nb, d));
2302 }
2303 }
2304 }
2305 }
2306 frontier = next_frontier;
2307 if frontier.is_empty() {
2308 break;
2309 }
2310 }
2311
2312 if results.is_empty() {
2313 return Ok(format!(
2314 "No semantic neighbors found for '{}'. Graph may not have been built with --llm.",
2315 query
2316 ));
2317 }
2318
2319 let seed_labels: Vec<String> = seed_ids
2320 .iter()
2321 .map(|id| {
2322 g.get_node(id)
2323 .map(|n| n.label.clone())
2324 .unwrap_or_else(|| id.clone())
2325 })
2326 .collect();
2327
2328 let mut lines = vec![format!(
2329 "Semantic neighbors of '{}' (seeds: {}, depth {}, min_confidence {:.2})",
2330 query,
2331 seed_labels.join(", "),
2332 depth,
2333 min_confidence
2334 )];
2335 for (id, d) in &results {
2336 let label = g
2337 .get_node(id)
2338 .map(|n| n.label.clone())
2339 .unwrap_or_else(|| id.clone());
2340 lines.push(format!(" [depth {}] {}", d, label));
2341 }
2342 Ok(lines.join("\n"))
2343 }
2344
2345 fn tool_graph_hierarchy(
2346 &self,
2347 args: &Map<String, Value>,
2348 ) -> std::result::Result<String, String> {
2349 let class_name = Self::str_arg(args, "class_name")?;
2350 let graph_name = args
2351 .get("graph")
2352 .and_then(|v| v.as_str())
2353 .unwrap_or("merged");
2354
2355 let g = self.load_module_serve_graph(graph_name)?;
2356
2357 let seed_id = g
2358 .nodes_iter()
2359 .find(|(id, n)| n.label.eq_ignore_ascii_case(class_name) || id.contains(class_name))
2360 .map(|(id, _)| id.to_string())
2361 .ok_or_else(|| format!("Node '{}' not found in graph '{}'.", class_name, graph_name))?;
2362
2363 let seed_label = g
2364 .get_node(&seed_id)
2365 .map(|n| n.label.clone())
2366 .unwrap_or_else(|| seed_id.clone());
2367
2368 let hierarchy_rels = ["extends", "implements", "inherits"];
2369
2370 let mut supertypes: Vec<String> = Vec::new();
2371 let mut subtypes: Vec<String> = Vec::new();
2372
2373 for edge in g.edges_iter() {
2374 let rel = edge.relation.to_lowercase();
2375 if !hierarchy_rels.contains(&rel.as_str()) {
2376 continue;
2377 }
2378 if edge.source == seed_id {
2379 let label = g
2380 .get_node(&edge.target)
2381 .map(|n| n.label.clone())
2382 .unwrap_or_else(|| edge.target.clone());
2383 supertypes.push(label);
2384 }
2385 if edge.target == seed_id {
2386 let label = g
2387 .get_node(&edge.source)
2388 .map(|n| n.label.clone())
2389 .unwrap_or_else(|| edge.source.clone());
2390 subtypes.push(label);
2391 }
2392 }
2393
2394 let mut lines = vec![format!("Hierarchy for '{}':", seed_label)];
2395 if supertypes.is_empty() {
2396 lines.push(" Supertypes: (none)".into());
2397 } else {
2398 lines.push(format!(" Supertypes: {}", supertypes.join(", ")));
2399 }
2400 lines.push(format!(" ↳ {}", seed_label));
2401 if subtypes.is_empty() {
2402 lines.push(" Subtypes: (none)".into());
2403 } else {
2404 for sub in &subtypes {
2405 lines.push(format!(" ↳ {}", sub));
2406 }
2407 }
2408 Ok(lines.join("\n"))
2409 }
2410
2411 pub(crate) fn get_source_cached(&self, path: &PathBuf) -> Option<(String, Vec<OutlineItem>)> {
2412 let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
2413
2414 {
2415 let cache = self.source_cache.lock().unwrap();
2416 if let Some((cached_mtime, content, items)) = cache.get(path) {
2417 if *cached_mtime == mtime {
2418 return Some((content.clone(), items.clone()));
2419 }
2420 }
2421 }
2422
2423 let content = std::fs::read_to_string(path).ok()?;
2424 let items = outline_items(path, &content);
2425
2426 let mut cache = self.source_cache.lock().unwrap();
2427 if cache.len() >= 1000 {
2428 cache.clear();
2429 }
2430 cache.insert(path.clone(), (mtime, content.clone(), items.clone()));
2431 Some((content, items))
2432 }
2433
2434 fn tool_graph_query_vector(
2435 &self,
2436 args: &Map<String, Value>,
2437 ) -> std::result::Result<String, String> {
2438 let query = Self::str_arg(args, "query")?;
2439 let graph_name = args
2440 .get("graph")
2441 .and_then(|v| v.as_str())
2442 .unwrap_or("merged");
2443 let top_k = Self::int_arg(args, "top_k", 8) as usize;
2444
2445 let g = self.load_module_serve_graph(graph_name)?;
2446 let dense = self
2447 .embedder
2448 .as_ref()
2449 .filter(|_| !self.node_embeddings.is_empty())
2450 .map(|e| (e, &self.node_embeddings));
2451 let result = query_graph_text_hybrid(&g, query, "bfs", 2, top_k * 200, None, dense);
2452 Ok(result)
2453 }
2454
2455 fn log_tool_call(&self, tool: &str, result_chars: usize, saved_chars: usize) {
2460 use std::io::Write as IoWrite;
2461 let path = self.global_dir.join("tool_stats.jsonl");
2462 let ts = SystemTime::now()
2463 .duration_since(SystemTime::UNIX_EPOCH)
2464 .unwrap_or_default()
2465 .as_secs_f64();
2466 let entry = serde_json::json!({
2467 "ts": ts,
2468 "tool": tool,
2469 "result_chars": result_chars,
2470 "saved_chars": saved_chars,
2471 });
2472 if let Ok(mut f) = std::fs::OpenOptions::new()
2473 .create(true)
2474 .append(true)
2475 .open(&path)
2476 {
2477 let _ = writeln!(f, "{}", entry);
2478 }
2479 self.telemetry.record_usage(tool, saved_chars, true);
2480 }
2481
2482 fn resolve_class_source(
2483 &self,
2484 g: &ServeGraph,
2485 class_name: &str,
2486 method_hint: Option<&str>,
2487 ) -> std::result::Result<PathBuf, String> {
2488 let candidates = find_nodes_by_label(g, class_name);
2489 if candidates.is_empty() {
2490 return Err(format!("No node matching '{}' found in graph.", class_name));
2491 }
2492 let mut paths: Vec<PathBuf> = Vec::new();
2493 for (id, node) in candidates.iter().take(10) {
2494 if !node.source_file.is_empty() {
2495 let p = PathBuf::from(&node.source_file);
2496 if p.exists() {
2497 paths.push(p);
2498 continue;
2499 }
2500 }
2501 for neighbor_id in g.neighbors(id) {
2502 if let Some(neighbor) = g.get_node(neighbor_id) {
2503 if !neighbor.source_file.is_empty() {
2504 let p = PathBuf::from(&neighbor.source_file);
2505 if p.exists() {
2506 paths.push(p);
2507 break;
2508 }
2509 }
2510 }
2511 }
2512 }
2513 if paths.is_empty() {
2514 return Err(format!(
2515 "Found '{}' in graph but source file not found on disk.",
2516 class_name
2517 ));
2518 }
2519 if let Some(mhint) = method_hint {
2520 if paths.len() > 1 {
2521 let mhint_lower = mhint.to_lowercase();
2522 for p in &paths {
2523 if let Ok(content) = std::fs::read_to_string(p) {
2524 let items = outline_items(p, &content);
2525 if items.iter().any(|i| {
2526 i.kind == "method" && i.name.to_lowercase().contains(&mhint_lower)
2527 }) {
2528 return Ok(p.clone());
2529 }
2530 }
2531 }
2532 }
2533 }
2534 Ok(paths[0].clone())
2535 }
2536
2537 fn tool_codesynapse_outline(
2538 &self,
2539 args: &Map<String, Value>,
2540 ) -> std::result::Result<String, String> {
2541 let class_name = Self::str_arg(args, "class_name")?;
2542 let graph_name = args
2543 .get("graph")
2544 .and_then(|v| v.as_str())
2545 .unwrap_or("merged");
2546 let g = self.load_module_serve_graph(graph_name)?;
2547 let path = self.resolve_class_source(&g, class_name, None)?;
2548 let content = std::fs::read_to_string(&path)
2549 .map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
2550 let items = outline_items(&path, &content);
2551 if items.is_empty() {
2552 return Ok(format!(
2553 "Could not extract outline from {} (unsupported format or empty).",
2554 path.display()
2555 ));
2556 }
2557 let total_lines = content.lines().count();
2558 let mut lines = vec![
2559 format!("File: {}", path.display()),
2560 format!("Size: {} lines", total_lines),
2561 String::new(),
2562 ];
2563 for item in &items {
2564 let prefix = match item.kind.as_str() {
2565 "class" => "CLASS ",
2566 "interface" => "IFACE ",
2567 "enum" => "ENUM ",
2568 "record" => "RECORD",
2569 "method" => " def ",
2570 "field" => " var ",
2571 _ => " ??? ",
2572 };
2573 lines.push(format!(" L{:<6} {} {}", item.line, prefix, item.name));
2574 }
2575 let result = lines.join("\n");
2576 self.log_tool_call(
2577 "codesynapse_outline",
2578 result.len(),
2579 content.len().saturating_sub(result.len()),
2580 );
2581 Ok(result)
2582 }
2583
2584 fn tool_codesynapse_read(
2585 &self,
2586 args: &Map<String, Value>,
2587 ) -> std::result::Result<String, String> {
2588 let class_name = Self::str_arg(args, "class_name")?;
2589 let graph_name = args
2590 .get("graph")
2591 .and_then(|v| v.as_str())
2592 .unwrap_or("merged");
2593 let from_line = Self::int_arg(args, "from_line", 1) as usize;
2594 let mut to_line = Self::int_arg(args, "to_line", 0) as usize;
2595
2596 let g = self.load_module_serve_graph(graph_name)?;
2597 let path = self.resolve_class_source(&g, class_name, None)?;
2598 let content = std::fs::read_to_string(&path)
2599 .map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
2600 let all_lines: Vec<&str> = content.lines().collect();
2601 let total = all_lines.len();
2602 if to_line == 0 {
2603 to_line = total;
2604 }
2605 if from_line > to_line {
2606 return Err(format!(
2607 "Invalid range: from_line ({}) > to_line ({})",
2608 from_line, to_line
2609 ));
2610 }
2611 let s = from_line.saturating_sub(1);
2612 let e = to_line.min(total);
2613 let numbered: Vec<String> = all_lines[s..e]
2614 .iter()
2615 .enumerate()
2616 .map(|(i, line)| format!("{:<6} {}", from_line + i, line))
2617 .collect();
2618 let result = format!(
2619 "File: {} ({} lines total)\n\n── lines {}-{} ──\n{}",
2620 path.display(),
2621 total,
2622 from_line,
2623 to_line,
2624 numbered.join("\n")
2625 );
2626 self.log_tool_call(
2627 "codesynapse_read",
2628 result.len(),
2629 content.len().saturating_sub(result.len()),
2630 );
2631 Ok(result)
2632 }
2633
2634 fn tool_codesynapse_read_method(
2635 &self,
2636 args: &Map<String, Value>,
2637 ) -> std::result::Result<String, String> {
2638 let class_name = Self::str_arg(args, "class_name")?;
2639 let method_name = Self::str_arg(args, "method_name")?;
2640 let graph_name = args
2641 .get("graph")
2642 .and_then(|v| v.as_str())
2643 .unwrap_or("merged");
2644
2645 let g = self.load_module_serve_graph(graph_name)?;
2646 let path = self.resolve_class_source(&g, class_name, Some(method_name))?;
2647 let content = std::fs::read_to_string(&path)
2648 .map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
2649 let source_lines: Vec<&str> = content.lines().collect();
2650 let items = outline_items(&path, &content);
2651
2652 match extract_method_range(&source_lines, &items, method_name) {
2653 None => {
2654 let methods: Vec<&str> = items
2655 .iter()
2656 .filter(|i| i.kind == "method")
2657 .map(|i| i.name.as_str())
2658 .take(20)
2659 .collect();
2660 Err(format!(
2661 "No method matching '{}' in {}.\nAvailable methods: {}",
2662 method_name,
2663 class_name,
2664 methods.join(", ")
2665 ))
2666 }
2667 Some((start_1, end_1)) => {
2668 let numbered: Vec<String> = source_lines[start_1 - 1..end_1]
2669 .iter()
2670 .enumerate()
2671 .map(|(i, line)| format!("{:<6} {}", start_1 + i, line))
2672 .collect();
2673 let result = format!(
2674 "File: {} (lines {}-{} of {})\n\n{}",
2675 path.display(),
2676 start_1,
2677 end_1,
2678 source_lines.len(),
2679 numbered.join("\n")
2680 );
2681 self.log_tool_call(
2682 "codesynapse_read_method",
2683 result.len(),
2684 content.len().saturating_sub(result.len()),
2685 );
2686 Ok(result)
2687 }
2688 }
2689 }
2690
2691 fn tool_codesynapse_read_with_callees(
2692 &self,
2693 args: &Map<String, Value>,
2694 ) -> std::result::Result<String, String> {
2695 let class_name = Self::str_arg(args, "class_name")?;
2696 let method_name = Self::str_arg(args, "method_name")?;
2697 let graph_name = args
2698 .get("graph")
2699 .and_then(|v| v.as_str())
2700 .unwrap_or("merged");
2701
2702 let g = self.load_module_serve_graph(graph_name)?;
2703 let path = self.resolve_class_source(&g, class_name, Some(method_name))?;
2704 let content = std::fs::read_to_string(&path)
2705 .map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
2706 let source_lines: Vec<&str> = content.lines().collect();
2707 let items = outline_items(&path, &content);
2708
2709 let mut all_methods: HashMap<String, (usize, usize)> = HashMap::new();
2710 for item in &items {
2711 if item.kind == "method" {
2712 if let Some(range) =
2713 extract_method_range(&source_lines, std::slice::from_ref(item), &item.name)
2714 {
2715 all_methods.insert(item.name.clone(), range);
2716 }
2717 }
2718 }
2719
2720 let (start_1, end_1) = match extract_method_range(&source_lines, &items, method_name) {
2721 None => {
2722 let methods: Vec<&str> = all_methods.keys().map(|s| s.as_str()).take(20).collect();
2723 return Err(format!(
2724 "No method matching '{}' in {}.\nAvailable methods: {}",
2725 method_name,
2726 class_name,
2727 methods.join(", ")
2728 ));
2729 }
2730 Some(r) => r,
2731 };
2732
2733 let render_method = |name: &str, s1: usize, e1: usize, indent: &str| -> String {
2734 let lines: Vec<String> = source_lines[s1 - 1..e1]
2735 .iter()
2736 .enumerate()
2737 .map(|(i, line)| format!("{}{:<6} {}", indent, s1 + i, line))
2738 .collect();
2739 format!(
2740 "{}--- {} (L{}-{}) ---\n{}",
2741 indent,
2742 name,
2743 s1,
2744 e1,
2745 lines.join("\n")
2746 )
2747 };
2748
2749 let mut sections = vec![render_method(method_name, start_1, end_1, "")];
2750
2751 let body = source_lines[start_1 - 1..end_1].join("\n");
2752 let mut callee_keys: Vec<String> = all_methods
2753 .keys()
2754 .filter(|&name| name != method_name && body.contains(&format!("{}(", name)))
2755 .cloned()
2756 .collect();
2757 callee_keys.sort();
2758 let mut seen = std::collections::HashSet::new();
2759 for callee_name in callee_keys {
2760 if seen.insert(callee_name.clone()) {
2761 if let Some(&(cs1, ce1)) = all_methods.get(&callee_name) {
2762 sections.push(render_method(&callee_name, cs1, ce1, " "));
2763 }
2764 }
2765 }
2766
2767 Ok(format!(
2768 "File: {}\n\n{}",
2769 path.display(),
2770 sections.join("\n\n")
2771 ))
2772 }
2773
2774 fn tool_codesynapse_find_callers(
2775 &self,
2776 args: &Map<String, Value>,
2777 ) -> std::result::Result<String, String> {
2778 let class_name = Self::str_arg(args, "class_name")?;
2779 let method_name = args
2780 .get("method_name")
2781 .and_then(|v| v.as_str())
2782 .unwrap_or("");
2783 let graph_name = args
2784 .get("graph")
2785 .and_then(|v| v.as_str())
2786 .unwrap_or("merged");
2787
2788 let g = self.load_module_serve_graph(graph_name)?;
2789 let candidates = find_nodes_by_label(&g, class_name);
2790 if candidates.is_empty() {
2791 return Ok(format!(
2792 "No node matching '{}' found in graph '{}'.",
2793 class_name, graph_name
2794 ));
2795 }
2796 let target_ids: std::collections::HashSet<String> = candidates
2797 .iter()
2798 .take(5)
2799 .map(|(id, _)| id.to_string())
2800 .collect();
2801
2802 let call_rels = ["calls", "uses", "invokes"];
2803 let mut callers: Vec<(String, String)> = Vec::new();
2804 for edge in g.edges_iter() {
2805 if !target_ids.contains(&edge.target) {
2806 continue;
2807 }
2808 if !call_rels.contains(&edge.relation.as_str()) {
2809 continue;
2810 }
2811 if let Some(caller_node) = g.get_node(&edge.source) {
2812 callers.push((caller_node.label.clone(), caller_node.source_file.clone()));
2813 }
2814 }
2815
2816 let subject = if method_name.is_empty() {
2817 class_name.to_string()
2818 } else {
2819 format!("{}.{}", class_name, method_name)
2820 };
2821
2822 if !callers.is_empty() {
2823 let mut lines = vec![format!(
2824 "Callers of {} ({} found via graph call edges):\n",
2825 subject,
2826 callers.len()
2827 )];
2828 for (label, src) in &callers {
2829 lines.push(format!(" {}", label));
2830 if !src.is_empty() {
2831 lines.push(format!(" [{}]", src));
2832 }
2833 }
2834 return Ok(lines.join("\n"));
2835 }
2836
2837 let roots = source_roots_from_graph(&g);
2838 if roots.is_empty() {
2839 return Ok(format!(
2840 "No graph call edges found for {}. No source roots detected.",
2841 subject
2842 ));
2843 }
2844 let search_term = if method_name.is_empty() {
2845 class_name
2846 } else {
2847 method_name
2848 };
2849 let pattern = Regex::new(&format!(r"\b{}\b", regex::escape(search_term)))
2850 .map_err(|e| e.to_string())?;
2851 let exts = ["java", "js", "ts", "tsx", "py", "kt", "rs", "go", "rb"];
2852 let hits = search_source_files(&roots, &exts, &pattern, 50);
2853
2854 if hits.is_empty() {
2855 return Ok(format!(
2856 "No callers found for {} (graph edges + source text search).",
2857 subject
2858 ));
2859 }
2860 let mut lines = vec![format!(
2861 "Callers of {} ({} source matches — no graph call edges):\n",
2862 subject,
2863 hits.len()
2864 )];
2865 for (rel, lineno, content) in &hits {
2866 lines.push(format!(" {}:{}", rel, lineno));
2867 lines.push(format!(" {}", content));
2868 }
2869 Ok(lines.join("\n"))
2870 }
2871
2872 fn tool_codesynapse_find_usages(
2873 &self,
2874 args: &Map<String, Value>,
2875 ) -> std::result::Result<String, String> {
2876 let class_name = Self::str_arg(args, "class_name")?;
2877 let graph_name = args
2878 .get("graph")
2879 .and_then(|v| v.as_str())
2880 .unwrap_or("merged");
2881
2882 let g = self.load_module_serve_graph(graph_name)?;
2883 let roots = source_roots_from_graph(&g);
2884 if roots.is_empty() {
2885 return Ok(
2886 "No source roots detected in graph. Ensure graph was built with absolute paths."
2887 .into(),
2888 );
2889 }
2890 let pattern = Regex::new(&format!(r"\b{}\b", regex::escape(class_name)))
2891 .map_err(|e| e.to_string())?;
2892 let exts = [
2893 "java", "js", "ts", "tsx", "py", "kt", "rs", "go", "rb", "cls",
2894 ];
2895 let hits = search_source_files(&roots, &exts, &pattern, 60);
2896
2897 if hits.is_empty() {
2898 return Ok(format!(
2899 "No usages of '{}' found across source roots.",
2900 class_name
2901 ));
2902 }
2903 let truncated = hits.len() >= 60;
2904 let mut lines = vec![format!(
2905 "Usages of {} ({}{} matches):\n",
2906 class_name,
2907 hits.len(),
2908 if truncated { "+" } else { "" }
2909 )];
2910 let mut prev_file = String::new();
2911 for (rel, lineno, content) in &hits {
2912 if *rel != prev_file {
2913 lines.push(format!(" {}:", rel));
2914 prev_file = rel.clone();
2915 }
2916 lines.push(format!(" L{}: {}", lineno, content));
2917 }
2918 if truncated {
2919 lines.push("\n (truncated at 60 matches — use a more specific name)".into());
2920 }
2921 Ok(lines.join("\n"))
2922 }
2923
2924 fn tool_codesynapse_context(
2925 &self,
2926 args: &Map<String, Value>,
2927 ) -> std::result::Result<String, String> {
2928 let query = Self::str_arg(args, "query")?;
2929 let graph_name = args
2930 .get("graph")
2931 .and_then(|v| v.as_str())
2932 .unwrap_or("merged");
2933 let max_chars = Self::int_arg(args, "max_chars", 16000) as usize;
2934
2935 let g = self.load_module_serve_graph(graph_name)?;
2936 let dense = self
2937 .embedder
2938 .as_ref()
2939 .filter(|_| !self.node_embeddings.is_empty())
2940 .map(|e| (e, &self.node_embeddings));
2941
2942 let result = context_query(&g, query, dense);
2943
2944 let query_lower = query.to_lowercase();
2945 let query_tokens: Vec<&str> = query_lower.split_whitespace().collect();
2946
2947 let mut out = String::new();
2948 let mut total_chars = 0usize;
2949
2950 if result.fallback {
2951 out.push_str("[No exact match — showing semantic results]\n\n");
2952 }
2953
2954 let render_group = |header: &str,
2955 nodes: &[(String, String, String)],
2956 out: &mut String,
2957 total_chars: &mut usize,
2958 max_chars: usize,
2959 query_tokens: &[&str],
2960 require_source: bool|
2961 -> (bool, usize) {
2962 if nodes.is_empty() {
2963 return (true, 0);
2964 }
2965 let mut rendered = 0usize;
2966 let mut header_written = false;
2967 for (_, label, source_file) in nodes {
2968 if *total_chars >= max_chars {
2969 return (false, rendered);
2970 }
2971 let path = std::path::PathBuf::from(source_file);
2972 let source = self.get_source_cached(&path);
2973 if require_source && source.is_none() {
2974 continue; }
2976 if !header_written {
2977 out.push_str(&format!("## {}\n", header));
2978 header_written = true;
2979 }
2980 let header_line = format!(
2981 "### {} ({})\n",
2982 label,
2983 path.file_name()
2984 .and_then(|f| f.to_str())
2985 .unwrap_or(source_file)
2986 );
2987 out.push_str(&header_line);
2988 rendered += 1;
2989
2990 if let Some((content, items)) = source {
2991 let (content, items) = (content, items);
2992 let source_lines: Vec<&str> = content.lines().collect();
2993
2994 let mut scored: Vec<(usize, &OutlineItem)> = items
2996 .iter()
2997 .filter(|i| matches!(i.kind.as_str(), "method" | "function"))
2998 .map(|item| {
2999 let name_lower = item.name.to_lowercase();
3000 let label_lower = label.to_lowercase();
3001 let score = if name_lower == label_lower {
3002 100
3003 } else {
3004 query_tokens
3005 .iter()
3006 .filter(|t| name_lower.contains(*t))
3007 .count()
3008 };
3009 (score, item)
3010 })
3011 .collect();
3012 scored.sort_by_key(|k| std::cmp::Reverse(k.0));
3013
3014 let mut wrote_body = false;
3015 for &(_, item) in &scored {
3016 if let Some((start, end)) =
3017 extract_method_range(&source_lines, &items, &item.name)
3018 {
3019 let body: Vec<String> = source_lines[start - 1..end]
3020 .iter()
3021 .enumerate()
3022 .map(|(i, l)| format!("{:<6} {}", start + i, l))
3023 .collect();
3024 out.push_str(&body.join("\n"));
3025 out.push('\n');
3026 wrote_body = true;
3027 break;
3028 }
3029 }
3030 if !wrote_body {
3031 let numbered: Vec<String> = content
3032 .lines()
3033 .enumerate()
3034 .map(|(i, l)| format!("{:<6} {}", i + 1, l))
3035 .collect();
3036 let joined = numbered.join("\n");
3037 let snippet: String = joined.chars().take(2048).collect();
3038 out.push_str(&snippet);
3039 out.push('\n');
3040 } else if let Some(method_item) = scored.first().map(|(_, i)| *i) {
3041 let method_line = method_item.line;
3042 let parent_class = items
3043 .iter()
3044 .filter(|i| i.kind == "class" && i.line <= method_line)
3045 .max_by_key(|i| i.line);
3046 if let Some(cls) = parent_class {
3047 let next_class_line = items
3048 .iter()
3049 .filter(|i| i.kind == "class" && i.line > cls.line)
3050 .map(|i| i.line)
3051 .min()
3052 .unwrap_or(usize::MAX);
3053 let sibling_count = items
3054 .iter()
3055 .filter(|i| {
3056 matches!(i.kind.as_str(), "method" | "function")
3057 && i.line > cls.line
3058 && i.line < next_class_line
3059 && i.name != method_item.name
3060 })
3061 .count();
3062 if sibling_count > 0 {
3063 out.push_str(&format!(
3064 "> {} has {} other method(s) not shown — \
3065 call codesynapse_read(\"{}\") to see the full class\n",
3066 cls.name, sibling_count, cls.name
3067 ));
3068 }
3069 }
3070 }
3071 } out.push('\n');
3073 *total_chars = out.len();
3074 }
3075 (true, rendered)
3076 };
3077
3078 type NodeGroup<'a> = (&'a str, &'a [(String, String, String)]);
3079 let groups: &[NodeGroup<'_>] = &[
3080 ("Entry Points", &result.entry_points),
3081 ("Callers", &result.callers),
3082 ("Callees", &result.callees),
3083 ];
3084
3085 let mut entry_rendered = 0usize;
3086 let mut fell_back = result.fallback;
3087 for (header, nodes) in groups {
3088 let (completed, rendered) = render_group(
3089 header,
3090 nodes,
3091 &mut out,
3092 &mut total_chars,
3093 max_chars,
3094 &query_tokens,
3095 true, );
3097 if *header == "Entry Points" {
3098 entry_rendered = rendered;
3099 }
3100 if !completed {
3101 out.push_str("\n[truncated — raise max_chars to see more]\n");
3102 break;
3103 }
3104 }
3105
3106 if !result.fallback && entry_rendered == 0 && !result.entry_points.is_empty() {
3109 out.clear();
3110 out.push_str("[No exact match — showing semantic results]\n\n");
3111 let fallback_nodes = query_top_nodes(&g, query, 5, dense);
3112 render_group(
3113 "Entry Points",
3114 &fallback_nodes,
3115 &mut out,
3116 &mut total_chars,
3117 max_chars,
3118 &query_tokens,
3119 false, );
3121 fell_back = true;
3122 }
3123
3124 if entry_rendered > 0 && result.callers.is_empty() && !fell_back {
3125 out.push_str(
3126 "\n> No callers found — symbol is defined but not called within this graph.\n",
3127 );
3128 }
3129
3130 if out.trim().is_empty() || out.trim() == "[No exact match — showing semantic results]" {
3131 return Ok("No results found.".into());
3132 }
3133
3134 if !fell_back {
3135 let node_count = g.num_nodes();
3136 out.push_str(&format!(
3137 "\n---\n> Exact match found (graph has {} indexed nodes). \
3138 The entry points and source above cover the relevant mechanism. \
3139 Answer from this context.\n",
3140 node_count
3141 ));
3142 }
3143
3144 self.log_tool_call("codesynapse_context", out.len(), total_chars);
3145 Ok(out)
3146 }
3147
3148 fn tool_codesynapse_resolve(
3149 &self,
3150 args: &Map<String, Value>,
3151 ) -> std::result::Result<String, String> {
3152 let query = Self::str_arg(args, "query")?;
3153 let graph_name = args
3154 .get("graph")
3155 .and_then(|v| v.as_str())
3156 .unwrap_or("merged");
3157 let top_k = Self::int_arg(args, "top_k", 3) as usize;
3158 let max_chars = Self::int_arg(args, "max_chars", 24000) as usize;
3159
3160 let g = self.load_module_serve_graph(graph_name)?;
3161 let dense = self
3162 .embedder
3163 .as_ref()
3164 .filter(|_| !self.node_embeddings.is_empty())
3165 .map(|e| (e, &self.node_embeddings));
3166
3167 let top_nodes = query_top_nodes(&g, query, top_k, dense);
3168 if top_nodes.is_empty() {
3169 return Ok("No matching nodes found.".into());
3170 }
3171
3172 let low_confidence = top_nodes.len() > 1 && {
3173 let first_file = &top_nodes[0].2;
3174 top_nodes.iter().all(|(_, _, f)| f == first_file)
3175 };
3176
3177 let query_lower = query.to_lowercase();
3178 let query_tokens: Vec<&str> = query_lower.split_whitespace().collect();
3179
3180 let mut sections: Vec<String> = Vec::new();
3181 let mut total_chars = 0usize;
3182 let mut raw_chars = 0usize;
3183
3184 for (node_id, label, source_file) in &top_nodes {
3185 if total_chars >= max_chars {
3186 break;
3187 }
3188 let path = PathBuf::from(source_file);
3189 let (content, items) = match self.get_source_cached(&path) {
3190 Some(v) => v,
3191 None => continue,
3192 };
3193 raw_chars += content.len();
3194 let source_lines: Vec<&str> = content.lines().collect();
3195
3196 let mut scored_methods: Vec<(usize, &OutlineItem)> = items
3197 .iter()
3198 .filter(|i| i.kind == "method")
3199 .map(|item| {
3200 let name_lower = item.name.to_lowercase();
3201 let score = query_tokens
3202 .iter()
3203 .filter(|t| name_lower.contains(*t))
3204 .count();
3205 (score, item)
3206 })
3207 .collect();
3208 scored_methods.sort_by_key(|k| std::cmp::Reverse(k.0));
3209
3210 let caller_count = g
3211 .edges_iter()
3212 .filter(|e| e.target == *node_id && e.relation.contains("call"))
3213 .count();
3214 let caller_note = if caller_count == 0 {
3215 " [0 explicit callers — may be entry point, registered callback, or unused]"
3216 .to_string()
3217 } else {
3218 format!(" [{} caller(s)]", caller_count)
3219 };
3220 let mut sec = format!("═══ {} ({}){}\n", label, path.display(), caller_note);
3221
3222 for item in &items {
3223 let prefix = match item.kind.as_str() {
3224 "class" | "interface" | "enum" | "record" => "CLASS",
3225 "method" => " def",
3226 "field" => " var",
3227 _ => " ",
3228 };
3229 sec.push_str(&format!(" L{:<6} {} {}\n", item.line, prefix, item.name));
3230 }
3231 sec.push('\n');
3232
3233 for &(score, item) in &scored_methods {
3234 if total_chars + sec.len() >= max_chars {
3235 break;
3236 }
3237 if let Some((start, end)) = extract_method_range(&source_lines, &items, &item.name)
3238 {
3239 let body: Vec<String> = source_lines[start - 1..end]
3240 .iter()
3241 .enumerate()
3242 .map(|(i, l)| format!("{:<6} {}", start + i, l))
3243 .collect();
3244 let method_block = format!(
3245 "── {}() [L{}-{}] {}\n{}\n",
3246 item.name,
3247 start,
3248 end,
3249 if score > 0 { "★" } else { "" },
3250 body.join("\n")
3251 );
3252 if method_block.len() > 4000 && score == 0 {
3253 continue;
3254 }
3255 sec.push_str(&method_block);
3256 }
3257 }
3258
3259 total_chars += sec.len();
3260 sections.push(sec);
3261 }
3262
3263 let mut result = sections.join("\n");
3264 if low_confidence {
3265 result = format!(
3266 "[low confidence — results are from one file; try rephrasing or use Bash to grep]\n\n{}",
3267 result
3268 );
3269 }
3270 self.log_tool_call(
3271 "codesynapse_resolve",
3272 result.len(),
3273 raw_chars.saturating_sub(result.len()),
3274 );
3275 Ok(result)
3276 }
3277
3278 fn tool_graph_build(&self, _args: &Map<String, Value>) -> std::result::Result<String, String> {
3279 *self.last_mtime.lock().unwrap() = None;
3280 self.graph_cache.lock().unwrap().clear();
3281 let merged = self.global_dir.join("global-graph.json");
3282 if !merged.exists() {
3283 return Ok(
3284 "global-graph.json not found. Run `codesynapse global add <path>` to register graphs.".into()
3285 );
3286 }
3287 let size_kb = std::fs::metadata(&merged)
3288 .map(|m| m.len() / 1024)
3289 .unwrap_or(0);
3290 let manifest_path = self.global_dir.join("global-manifest.json");
3291 let n_modules = if manifest_path.exists() {
3292 std::fs::read_to_string(&manifest_path)
3293 .ok()
3294 .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
3295 .and_then(|v| v.get("repos").and_then(|r| r.as_object()).map(|m| m.len()))
3296 .unwrap_or(0)
3297 } else {
3298 0
3299 };
3300 Ok(format!(
3301 "Graph cache cleared. Next query will reload from disk.\n global-graph.json: {}KB\n manifest: {} module(s)",
3302 size_kb, n_modules
3303 ))
3304 }
3305
3306 fn tool_codesynapse_stats(
3307 &self,
3308 _args: &Map<String, Value>,
3309 ) -> std::result::Result<String, String> {
3310 let path = self.global_dir.join("tool_stats.jsonl");
3311 if !path.exists() {
3312 return Ok(
3313 "No usage data yet — stats are recorded as you use codesynapse tools.".into(),
3314 );
3315 }
3316 let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
3317 let entries: Vec<serde_json::Value> = text
3318 .lines()
3319 .filter(|l| !l.trim().is_empty())
3320 .filter_map(|l| serde_json::from_str(l).ok())
3321 .collect();
3322 if entries.is_empty() {
3323 return Ok("No usage data yet.".into());
3324 }
3325
3326 let now = SystemTime::now()
3327 .duration_since(SystemTime::UNIX_EPOCH)
3328 .unwrap_or_default()
3329 .as_secs_f64();
3330
3331 let sum_saved = |subset: &[&serde_json::Value]| -> f64 {
3332 subset
3333 .iter()
3334 .map(|e| e.get("saved_chars").and_then(|v| v.as_f64()).unwrap_or(0.0))
3335 .sum::<f64>()
3336 / 4.0
3337 };
3338 let fmt_tok = |n: f64| -> String {
3339 if n >= 1_000_000.0 {
3340 format!("{:.1}M", n / 1_000_000.0)
3341 } else if n >= 1_000.0 {
3342 format!("{:.1}k", n / 1_000.0)
3343 } else {
3344 format!("{:.0}", n)
3345 }
3346 };
3347 let bar = |value: f64, max: f64| -> String {
3349 if max <= 0.0 {
3350 return "░".repeat(22);
3351 }
3352 let filled = ((value / max) * 22.0) as usize;
3353 let filled = filled.min(22);
3354 format!("{}{}", "█".repeat(filled), "░".repeat(22 - filled))
3355 };
3356
3357 let today: Vec<&serde_json::Value> = entries
3358 .iter()
3359 .filter(|e| now - e.get("ts").and_then(|v| v.as_f64()).unwrap_or(0.0) < 86400.0)
3360 .collect();
3361 let week: Vec<&serde_json::Value> = entries
3362 .iter()
3363 .filter(|e| now - e.get("ts").and_then(|v| v.as_f64()).unwrap_or(0.0) < 7.0 * 86400.0)
3364 .collect();
3365 let all: Vec<&serde_json::Value> = entries.iter().collect();
3366
3367 let today_saved = sum_saved(&today);
3368 let week_saved = sum_saved(&week);
3369 let total_saved = sum_saved(&all);
3370 let max_saved = today_saved.max(week_saved).max(total_saved).max(1.0);
3371
3372 let row = |label: &str, count: usize, saved: f64| -> String {
3377 format!(
3378 "║ {:<8} │ {:>4} calls │ {:>8} tokens │ {} ║",
3379 label,
3380 count,
3381 fmt_tok(saved),
3382 bar(saved, max_saved)
3383 )
3384 };
3385
3386 let mut tool_counts: std::collections::HashMap<String, usize> =
3388 std::collections::HashMap::new();
3389 for e in &entries {
3390 if let Some(t) = e.get("tool").and_then(|v| v.as_str()) {
3391 *tool_counts.entry(t.to_string()).or_insert(0) += 1;
3392 }
3393 }
3394 let mut ranked: Vec<(String, usize)> = tool_counts.into_iter().collect();
3395 ranked.sort_by_key(|b| std::cmp::Reverse(b.1));
3396
3397 let top = "╔══════════════════════════════════════════════════════════════════╗";
3398 let col_div = "╠══════════╪════════════╪═════════════════╪════════════════════════╣";
3400 let full_div = "╠══════════════════════════════════════════════════════════════════╣";
3401 let bot = "╚══════════════════════════════════════════════════════════════════╝";
3402
3403 let mut lines = vec![
3404 String::new(),
3405 top.into(),
3406 format!("║{:^66}║", " codesynapse -- usage stats "),
3407 col_div.into(),
3408 row("Today", today.len(), today_saved),
3409 row("Last 7d", week.len(), week_saved),
3410 row("All time", entries.len(), total_saved),
3411 ];
3412
3413 if !ranked.is_empty() {
3414 lines.push(full_div.into());
3415 lines.push(format!("║{:^66}║", " top tools "));
3416 lines.push(full_div.into());
3417 for (i, (tool, count)) in ranked.iter().take(5).enumerate() {
3418 let short = tool.strip_prefix("codesynapse_").unwrap_or(tool);
3419 let short = if short.len() > 55 {
3420 &short[..55]
3421 } else {
3422 short
3423 };
3424 let label = format!(" {}. {}", i + 1, short);
3425 let content = format!("{:<60}{:>6}", label, count);
3427 lines.push(format!("║{}║", content));
3428 }
3429 }
3430
3431 lines.push(bot.into());
3432 lines.push(String::new());
3433 Ok(lines.join("\n"))
3434 }
3435}
3436
3437#[cfg(test)]
3438mod tests {
3439 use super::*;
3440 use codesynapse_core::graph::MemoryGraphStore;
3441 use codesynapse_core::types::{Edge, Node};
3442 use serde_json::Map;
3443
3444 fn setup_store() -> StoreBackend {
3445 let store = MemoryGraphStore::new();
3446 let backend = StoreBackend::Memory(store);
3447 let nodes = vec![
3448 node("a", "NodeA"),
3449 node("b", "NodeB"),
3450 node("c", "NodeC"),
3451 node("d", "NodeD"),
3452 ];
3453 for n in &nodes {
3454 backend.add_node(n.clone()).unwrap();
3455 }
3456 for e in &[
3457 edge("a", "b", "calls"),
3458 edge("b", "c", "calls"),
3459 edge("c", "d", "calls"),
3460 ] {
3461 backend.add_edge(e.clone()).unwrap();
3462 }
3463 backend
3464 }
3465
3466 fn make_server(backend: StoreBackend) -> McpServer {
3467 let global_dir = PathBuf::from("/nonexistent-test-global");
3468 McpServer {
3469 backend: Mutex::new(backend),
3470 graph_path: None,
3471 last_mtime: Mutex::new(None),
3472 telemetry: Arc::new(codesynapse_core::telemetry::Telemetry::new(
3473 global_dir.clone(),
3474 )),
3475 global_dir,
3476 embedder: None,
3477 node_embeddings: HashMap::new(),
3478 graph_cache: Mutex::new(HashMap::new()),
3479 source_cache: Mutex::new(HashMap::new()),
3480 stale_check: Mutex::new(None),
3481 }
3482 }
3483
3484 fn make_server_with_global(backend: StoreBackend, global_dir: PathBuf) -> McpServer {
3485 McpServer {
3486 backend: Mutex::new(backend),
3487 graph_path: None,
3488 last_mtime: Mutex::new(None),
3489 telemetry: Arc::new(codesynapse_core::telemetry::Telemetry::new(
3490 global_dir.clone(),
3491 )),
3492 global_dir,
3493 embedder: None,
3494 node_embeddings: HashMap::new(),
3495 graph_cache: Mutex::new(HashMap::new()),
3496 source_cache: Mutex::new(HashMap::new()),
3497 stale_check: Mutex::new(None),
3498 }
3499 }
3500
3501 fn node(id: &str, label: &str) -> Node {
3502 Node {
3503 id: id.to_string(),
3504 label: label.to_string(),
3505 file_type: "code".to_string(),
3506 source_file: "test.rs".to_string(),
3507 source_location: None,
3508 community: None,
3509 rationale: None,
3510 docstring: None,
3511 metadata: HashMap::new(),
3512 }
3513 }
3514
3515 fn edge(src: &str, tgt: &str, rel: &str) -> Edge {
3516 Edge {
3517 source: src.to_string(),
3518 target: tgt.to_string(),
3519 relation: rel.to_string(),
3520 confidence: "EXTRACTED".to_string(),
3521 source_file: Some("test.rs".to_string()),
3522 weight: 1.0,
3523 context: None,
3524 }
3525 }
3526
3527 #[test]
3528 fn test_tools_list() {
3529 let tools = tool_defs();
3530 assert_eq!(tools.len(), 33);
3531 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
3532 assert!(names.contains(&"codesynapse_context"));
3533 assert!(names.contains(&"codesynapse_query_graph"));
3534 assert!(names.contains(&"codesynapse_get_node"));
3535 assert!(names.contains(&"codesynapse_get_neighbors"));
3536 assert!(names.contains(&"codesynapse_get_community"));
3537 assert!(names.contains(&"codesynapse_god_nodes"));
3538 assert!(names.contains(&"codesynapse_graph_stats"));
3539 assert!(names.contains(&"codesynapse_shortest_path"));
3540 assert!(names.contains(&"codesynapse_find_all_paths"));
3541 assert!(names.contains(&"codesynapse_weighted_path"));
3542 assert!(names.contains(&"codesynapse_community_bridges"));
3543 assert!(names.contains(&"codesynapse_diff"));
3544 assert!(names.contains(&"codesynapse_pagerank"));
3545 assert!(names.contains(&"codesynapse_detect_cycles"));
3546 assert!(names.contains(&"codesynapse_smart_summary"));
3547 assert!(names.contains(&"codesynapse_find_similar"));
3548 assert!(names.contains(&"codesynapse_query_vector"));
3549 assert!(names.contains(&"codesynapse_blast_radius"));
3550 assert!(names.contains(&"codesynapse_blast_radius_scored"));
3551 assert!(names.contains(&"codesynapse_blast_radius_multi"));
3552 assert!(names.contains(&"codesynapse_query_semantic"));
3553 assert!(names.contains(&"codesynapse_hierarchy"));
3554 assert!(names.contains(&"codesynapse_list_graphs"));
3555 assert!(names.contains(&"codesynapse_module_summary"));
3556 assert!(names.contains(&"codesynapse_outline"));
3557 assert!(names.contains(&"codesynapse_read"));
3558 assert!(names.contains(&"codesynapse_read_method"));
3559 assert!(names.contains(&"codesynapse_read_with_callees"));
3560 assert!(names.contains(&"codesynapse_find_callers"));
3561 assert!(names.contains(&"codesynapse_find_usages"));
3562 assert!(names.contains(&"codesynapse_build"));
3563 assert!(names.contains(&"codesynapse_stats"));
3564 }
3565
3566 #[test]
3567 fn test_tools_list_json() {
3568 let server = make_server(setup_store());
3569 let request = JsonRpcRequest {
3570 id: Some(serde_json::json!(1)),
3571 method: "tools/list".into(),
3572 params: None,
3573 };
3574 let response = server.handle_request(&request);
3575 assert_eq!(response.id, Some(serde_json::json!(1)));
3576 assert!(response.error.is_none());
3577 let result = response.result.unwrap();
3578 let tools = result["tools"].as_array().unwrap();
3579 assert_eq!(tools.len(), 33);
3580 }
3581
3582 #[test]
3583 fn test_tool_graph_stats() {
3584 let server = make_server(setup_store());
3585 let result = server.tool_graph_stats(&Map::new()).unwrap();
3586 assert!(result.contains("Nodes: 4"));
3587 assert!(result.contains("Edges: 3"));
3588 }
3589
3590 #[test]
3591 fn test_tool_get_node_found() {
3592 let server = make_server(setup_store());
3593 let mut args = Map::new();
3594 args.insert("node_id".to_string(), serde_json::json!("a"));
3595 let result = server.tool_get_node(&args).unwrap();
3596 assert!(result.contains("NodeA"));
3597 }
3598
3599 #[test]
3600 fn test_tool_get_node_not_found() {
3601 let server = make_server(setup_store());
3602 let mut args = Map::new();
3603 args.insert("node_id".to_string(), serde_json::json!("z"));
3604 let result = server.tool_get_node(&args).unwrap();
3605 assert!(result.contains("not found"));
3606 }
3607
3608 #[test]
3609 fn test_tool_god_nodes() {
3610 let server = make_server(setup_store());
3611 let mut args = Map::new();
3612 args.insert("top_n".to_string(), serde_json::json!(3));
3613 let result = server.tool_god_nodes(&args).unwrap();
3614 assert!(result.contains("god node"));
3615 }
3616
3617 #[test]
3618 fn test_tool_shortest_path() {
3619 let server = make_server(setup_store());
3620 let mut args = Map::new();
3621 args.insert("source".to_string(), serde_json::json!("a"));
3622 args.insert("target".to_string(), serde_json::json!("d"));
3623 let result = server.tool_shortest_path(&args).unwrap();
3624 assert!(result.contains("NodeA"));
3625 assert!(result.contains("NodeD"));
3626 }
3627
3628 #[test]
3629 fn test_tool_find_all_paths() {
3630 let server = make_server(setup_store());
3631 let mut args = Map::new();
3632 args.insert("source".to_string(), serde_json::json!("a"));
3633 args.insert("target".to_string(), serde_json::json!("d"));
3634 args.insert("max_length".to_string(), serde_json::json!(5));
3635 let result = server.tool_find_all_paths(&args).unwrap();
3636 assert!(result.contains("a -> b"));
3637 }
3638
3639 #[test]
3640 fn test_tool_smart_summary_architecture() {
3641 let server = make_server(setup_store());
3642 let mut args = Map::new();
3643 args.insert("level".to_string(), serde_json::json!("architecture"));
3644 let result = server.tool_smart_summary(&args).unwrap();
3645 assert!(result.contains("Nodes: 4"));
3646 assert!(result.contains("Edges: 3"));
3647 }
3648
3649 #[test]
3650 fn test_tool_unknown_method() {
3651 let server = make_server(setup_store());
3652 let request = JsonRpcRequest {
3653 id: Some(serde_json::json!(1)),
3654 method: "unknown".into(),
3655 params: None,
3656 };
3657 let response = server.handle_request(&request);
3658 assert!(response.error.is_some());
3659 }
3660
3661 #[test]
3662 fn test_tool_unknown_tool() {
3663 let server = make_server(setup_store());
3664 let mut args = Map::new();
3665 args.insert("name".to_string(), serde_json::json!("nonexistent"));
3666 args.insert("arguments".to_string(), serde_json::json!({}));
3667 let params = serde_json::json!({"name": "nonexistent", "arguments": {}});
3668 let request = JsonRpcRequest {
3669 id: Some(serde_json::json!(1)),
3670 method: "tools/call".into(),
3671 params: Some(params),
3672 };
3673 let response = server.handle_request(&request);
3674 assert!(response.error.is_some());
3675 }
3676
3677 #[test]
3678 fn test_server_hotreload_detects_mtime_change() {
3679 use std::io::Cursor;
3680 use std::time::Duration;
3681
3682 let graph_file = std::env::temp_dir().join("codesynapse_hotreload_test.tmp");
3683 std::fs::write(&graph_file, b"placeholder").unwrap();
3684
3685 let global_dir = PathBuf::from("/nonexistent-test-global");
3686 let server = McpServer {
3687 backend: Mutex::new(setup_store()),
3688 graph_path: Some(graph_file.clone()),
3689 last_mtime: Mutex::new(None),
3690 telemetry: Arc::new(codesynapse_core::telemetry::Telemetry::new(
3691 global_dir.clone(),
3692 )),
3693 global_dir,
3694 embedder: None,
3695 node_embeddings: HashMap::new(),
3696 graph_cache: Mutex::new(HashMap::new()),
3697 source_cache: Mutex::new(HashMap::new()),
3698 stale_check: Mutex::new(None),
3699 };
3700
3701 let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n";
3702 server
3703 .run_on(Cursor::new(input.as_bytes()), Vec::new())
3704 .unwrap();
3705 let mtime_after_first = *server.last_mtime.lock().unwrap();
3706 assert!(
3707 mtime_after_first.is_some(),
3708 "mtime should be recorded after first call"
3709 );
3710
3711 let old_mtime = mtime_after_first.unwrap() - Duration::from_secs(10);
3713 *server.last_mtime.lock().unwrap() = Some(old_mtime);
3714
3715 std::thread::sleep(Duration::from_millis(20));
3717 std::fs::write(&graph_file, b"updated").unwrap();
3718
3719 server
3720 .run_on(Cursor::new(input.as_bytes()), Vec::new())
3721 .unwrap();
3722 let mtime_after_second = *server.last_mtime.lock().unwrap();
3723 assert_ne!(
3724 Some(old_mtime),
3725 mtime_after_second,
3726 "mtime should be refreshed after file change"
3727 );
3728
3729 let _ = std::fs::remove_file(&graph_file);
3730 }
3731
3732 #[test]
3733 fn test_blank_stdin_lines_do_not_crash() {
3734 use std::io::Cursor;
3735 let server = make_server(setup_store());
3736 let input = "\n \n\t\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n\n";
3738 let reader = Cursor::new(input.as_bytes());
3739 let mut output = Vec::new();
3740 server
3741 .run_on(reader, &mut output)
3742 .expect("should not crash on blank lines");
3743 let out_str = String::from_utf8(output).unwrap();
3744 assert!(
3745 !out_str.is_empty(),
3746 "should produce response for valid line"
3747 );
3748 assert!(
3749 out_str.contains("tools"),
3750 "response should include tool listing"
3751 );
3752 assert_eq!(out_str.lines().count(), 1);
3754 }
3755
3756 fn write_test_graph(
3760 dir: &std::path::Path,
3761 name: &str,
3762 nodes: &[(&str, &str, &str)],
3763 edges: &[(&str, &str, &str)],
3764 ) -> PathBuf {
3765 let nodes_json: Vec<serde_json::Value> = nodes
3766 .iter()
3767 .map(|(id, label, ft)| serde_json::json!({"id": id, "label": label, "file_type": ft, "source_file": "src/x.rs"}))
3768 .collect();
3769 let edges_json: Vec<serde_json::Value> = edges
3770 .iter()
3771 .map(|(src, tgt, rel)| serde_json::json!({"source": src, "target": tgt, "relation": rel, "confidence": "EXTRACTED"}))
3772 .collect();
3773 let path = dir.join(name);
3774 std::fs::write(
3775 &path,
3776 serde_json::to_string(&serde_json::json!({"nodes": nodes_json, "edges": edges_json}))
3777 .unwrap(),
3778 )
3779 .unwrap();
3780 path
3781 }
3782
3783 fn write_manifest(global_dir: &std::path::Path, entries: &[(&str, &str, usize, usize)]) {
3784 use codesynapse_core::global_graph::{GlobalManifest, RepoManifestEntry};
3785 let mut manifest = GlobalManifest::default();
3786 for (tag, path, node_count, edge_count) in entries {
3787 manifest.repos.insert(
3788 tag.to_string(),
3789 RepoManifestEntry {
3790 added_at: "2026-01-01T00:00:00Z".into(),
3791 source_path: path.to_string(),
3792 node_count: *node_count,
3793 edge_count: *edge_count,
3794 source_hash: "abc123".into(),
3795 },
3796 );
3797 }
3798 std::fs::create_dir_all(global_dir).unwrap();
3799 std::fs::write(
3800 global_dir.join("global-manifest.json"),
3801 serde_json::to_string_pretty(&manifest).unwrap(),
3802 )
3803 .unwrap();
3804 }
3805
3806 #[test]
3807 fn test_graph_list_graphs_empty() {
3808 let tmp = tempfile::tempdir().unwrap();
3809 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3810 let result = server.tool_graph_list_graphs(&Map::new()).unwrap();
3811 assert!(result.contains("No graphs registered"));
3812 }
3813
3814 #[test]
3815 fn test_graph_list_graphs_with_entries() {
3816 let tmp = tempfile::tempdir().unwrap();
3817 let graph_path = write_test_graph(tmp.path(), "g.json", &[("a", "MyClass", "code")], &[]);
3818 write_manifest(
3819 tmp.path(),
3820 &[("mymodule", graph_path.to_str().unwrap(), 1, 0)],
3821 );
3822 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3823 let result = server.tool_graph_list_graphs(&Map::new()).unwrap();
3824 assert!(result.contains("mymodule"));
3825 assert!(result.contains("1"));
3826 }
3827
3828 #[test]
3829 fn test_graph_module_summary_not_found() {
3830 let tmp = tempfile::tempdir().unwrap();
3831 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3832 let mut args = Map::new();
3833 args.insert("module".into(), serde_json::json!("nonexistent"));
3834 let result = server.tool_graph_module_summary(&args);
3835 assert!(result.is_err());
3836 assert!(result.unwrap_err().contains("not found"));
3837 }
3838
3839 #[test]
3840 fn test_graph_module_summary_found() {
3841 let tmp = tempfile::tempdir().unwrap();
3842 let graph_path = write_test_graph(
3843 tmp.path(),
3844 "g.json",
3845 &[("a", "Alpha", "code"), ("b", "Beta", "code")],
3846 &[("a", "b", "calls")],
3847 );
3848 write_manifest(tmp.path(), &[("mymod", graph_path.to_str().unwrap(), 2, 1)]);
3849 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3850 let mut args = Map::new();
3851 args.insert("module".into(), serde_json::json!("mymod"));
3852 let result = server.tool_graph_module_summary(&args).unwrap();
3853 assert!(result.contains("mymod"));
3854 assert!(result.contains("Nodes:"));
3855 }
3856
3857 #[test]
3858 fn test_graph_blast_radius() {
3859 let tmp = tempfile::tempdir().unwrap();
3860 let graph_path = write_test_graph(
3862 tmp.path(),
3863 "g.json",
3864 &[
3865 ("a", "Alpha", "code"),
3866 ("b", "Beta", "code"),
3867 ("c", "Gamma", "code"),
3868 ("d", "Delta", "code"),
3869 ],
3870 &[("a", "b", "calls"), ("b", "c", "calls")],
3871 );
3872 std::fs::copy(&graph_path, tmp.path().join("global-graph.json")).unwrap();
3874 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3875 let mut args = Map::new();
3876 args.insert("class_name".into(), serde_json::json!("Alpha"));
3877 args.insert("depth".into(), serde_json::json!(3));
3878 let result = server.tool_graph_blast_radius(&args).unwrap();
3879 assert!(result.contains("Alpha"));
3880 assert!(result.contains("Beta") || result.contains("b"));
3881 }
3882
3883 #[test]
3884 fn test_graph_blast_radius_not_found() {
3885 let tmp = tempfile::tempdir().unwrap();
3886 write_test_graph(
3887 tmp.path(),
3888 "global-graph.json",
3889 &[("a", "Alpha", "code")],
3890 &[],
3891 );
3892 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3893 let mut args = Map::new();
3894 args.insert("class_name".into(), serde_json::json!("ZZZNotHere"));
3895 let result = server.tool_graph_blast_radius(&args);
3896 assert!(result.is_err());
3897 }
3898
3899 #[test]
3900 fn test_graph_hierarchy_no_edges() {
3901 let tmp = tempfile::tempdir().unwrap();
3902 write_test_graph(
3903 tmp.path(),
3904 "global-graph.json",
3905 &[("a", "Animal", "code"), ("b", "Dog", "code")],
3906 &[("b", "a", "extends")],
3907 );
3908 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3909 let mut args = Map::new();
3910 args.insert("class_name".into(), serde_json::json!("Animal"));
3911 let result = server.tool_graph_hierarchy(&args).unwrap();
3912 assert!(result.contains("Animal"));
3913 assert!(result.contains("Dog") || result.contains("b"));
3914 }
3915
3916 #[test]
3917 fn test_graph_query_vector_returns_result() {
3918 let tmp = tempfile::tempdir().unwrap();
3919 write_test_graph(
3920 tmp.path(),
3921 "global-graph.json",
3922 &[("a", "UserService", "code"), ("b", "AuthService", "code")],
3923 &[("a", "b", "calls")],
3924 );
3925 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
3926 let mut args = Map::new();
3927 args.insert("query".into(), serde_json::json!("user"));
3928 let result = server.tool_graph_query_vector(&args).unwrap();
3929 assert!(!result.is_empty());
3930 }
3931
3932 fn write_source_file(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
3936 let path = dir.join(name);
3937 std::fs::write(&path, content).unwrap();
3938 path
3939 }
3940
3941 fn write_graph_with_abs_source(
3942 tmp: &std::path::Path,
3943 graph_name: &str,
3944 src_abs: &str,
3945 ) -> PathBuf {
3946 let json = serde_json::json!({
3947 "nodes": [{"id": "cls1", "label": "MyClass", "file_type": "code", "source_file": src_abs}],
3948 "edges": []
3949 });
3950 let path = tmp.join(graph_name);
3951 std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
3952 path
3953 }
3954
3955 #[test]
3956 fn test_outline_java_basic() {
3957 let path = PathBuf::from("Foo.java");
3958 let content = "public class Foo {\n public void bar() {\n }\n}";
3959 let items = outline_items(&path, content);
3960 let kinds: Vec<&str> = items.iter().map(|i| i.kind.as_str()).collect();
3961 assert!(kinds.contains(&"class"), "should find class");
3962 assert!(kinds.contains(&"method"), "should find method");
3963 }
3964
3965 #[test]
3966 fn test_outline_python_basic() {
3967 let path = PathBuf::from("foo.py");
3968 let content = "class Foo:\n def bar(self):\n pass\n\ndef top_func():\n pass";
3969 let items = outline_items(&path, content);
3970 assert!(items.iter().any(|i| i.kind == "class" && i.name == "Foo"));
3971 assert!(items.iter().any(|i| i.kind == "method" && i.name == "bar"));
3972 assert!(items
3973 .iter()
3974 .any(|i| i.kind == "method" && i.name == "top_func"));
3975 }
3976
3977 #[test]
3978 fn test_outline_js_basic() {
3979 let path = PathBuf::from("foo.ts");
3980 let content = "export class Foo {\n doThing() {\n }\n}";
3981 let items = outline_items(&path, content);
3982 assert!(items.iter().any(|i| i.kind == "class" && i.name == "Foo"));
3983 assert!(items
3984 .iter()
3985 .any(|i| i.kind == "method" && i.name == "doThing"));
3986 }
3987
3988 #[test]
3989 fn test_detect_method_end_brace_tracking() {
3990 let lines: Vec<&str> = vec!["void foo() {", " if (x) { bar(); }", "}", "void next() {"];
3991 let end = detect_method_end(&lines, 0);
3992 assert_eq!(end, 2);
3993 }
3994
3995 #[test]
3996 fn test_extract_method_range_found() {
3997 let lines: Vec<&str> = vec!["public void doIt() {", " x++;", "}"];
3998 let items = vec![OutlineItem {
3999 kind: "method".into(),
4000 name: "doIt".into(),
4001 line: 1,
4002 }];
4003 let result = extract_method_range(&lines, &items, "doIt");
4004 assert!(result.is_some());
4005 let (s, e) = result.unwrap();
4006 assert_eq!(s, 1);
4007 assert_eq!(e, 3);
4008 }
4009
4010 #[test]
4011 fn test_source_roots_from_graph_empty() {
4012 let tmp = tempfile::tempdir().unwrap();
4013 write_test_graph(
4014 tmp.path(),
4015 "global-graph.json",
4016 &[("a", "Foo", "code")],
4017 &[],
4018 );
4019 let g = load_graph(&tmp.path().join("global-graph.json")).unwrap();
4020 let roots = source_roots_from_graph(&g);
4021 assert!(
4022 roots.is_empty(),
4023 "relative source_file paths should yield no roots"
4024 );
4025 }
4026
4027 #[test]
4028 fn test_synapse_outline_resolves_file() {
4029 let tmp = tempfile::tempdir().unwrap();
4030 let src = write_source_file(
4031 tmp.path(),
4032 "Foo.java",
4033 "public class Foo {\n public void bar() {}\n}",
4034 );
4035 write_graph_with_abs_source(tmp.path(), "global-graph.json", src.to_str().unwrap());
4036 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4037 let mut args = Map::new();
4038 args.insert("class_name".into(), serde_json::json!("MyClass"));
4039 let result = server.tool_codesynapse_outline(&args).unwrap();
4040 assert!(
4041 result.contains("Foo.java") || result.contains("File:"),
4042 "should include file info"
4043 );
4044 }
4045
4046 #[test]
4047 fn test_synapse_outline_not_found() {
4048 let tmp = tempfile::tempdir().unwrap();
4049 write_test_graph(
4050 tmp.path(),
4051 "global-graph.json",
4052 &[("a", "Foo", "code")],
4053 &[],
4054 );
4055 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4056 let mut args = Map::new();
4057 args.insert("class_name".into(), serde_json::json!("ZZZNotHere"));
4058 let result = server.tool_codesynapse_outline(&args);
4059 assert!(result.is_err() || result.unwrap().contains("not found"));
4060 }
4061
4062 #[test]
4063 fn test_synapse_read_line_range() {
4064 let tmp = tempfile::tempdir().unwrap();
4065 let src = write_source_file(tmp.path(), "Bar.java", "line1\nline2\nline3\nline4\nline5");
4066 write_graph_with_abs_source(tmp.path(), "global-graph.json", src.to_str().unwrap());
4067 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4068 let mut args = Map::new();
4069 args.insert("class_name".into(), serde_json::json!("MyClass"));
4070 args.insert("from_line".into(), serde_json::json!(2));
4071 args.insert("to_line".into(), serde_json::json!(3));
4072 let result = server.tool_codesynapse_read(&args).unwrap();
4073 assert!(result.contains("line2"));
4074 assert!(result.contains("line3"));
4075 assert!(!result.contains("line4"));
4076 }
4077
4078 #[test]
4079 fn test_synapse_read_method_found() {
4080 let tmp = tempfile::tempdir().unwrap();
4081 let src = write_source_file(
4082 tmp.path(),
4083 "Svc.java",
4084 "public class Svc {\n public void doWork() {\n int x = 1;\n }\n}",
4085 );
4086 write_graph_with_abs_source(tmp.path(), "global-graph.json", src.to_str().unwrap());
4087 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4088 let mut args = Map::new();
4089 args.insert("class_name".into(), serde_json::json!("MyClass"));
4090 args.insert("method_name".into(), serde_json::json!("doWork"));
4091 let result = server.tool_codesynapse_read_method(&args).unwrap();
4092 assert!(result.contains("doWork"));
4093 assert!(result.contains("int x = 1"));
4094 }
4095
4096 #[test]
4097 fn test_synapse_read_method_not_found() {
4098 let tmp = tempfile::tempdir().unwrap();
4099 let src = write_source_file(
4100 tmp.path(),
4101 "Svc.java",
4102 "public class Svc {\n public void doWork() {}\n}",
4103 );
4104 write_graph_with_abs_source(tmp.path(), "global-graph.json", src.to_str().unwrap());
4105 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4106 let mut args = Map::new();
4107 args.insert("class_name".into(), serde_json::json!("MyClass"));
4108 args.insert("method_name".into(), serde_json::json!("nonexistentMethod"));
4109 let result = server.tool_codesynapse_read_method(&args);
4110 assert!(result.is_err());
4111 assert!(result.unwrap_err().contains("No method matching"));
4112 }
4113
4114 #[test]
4115 fn test_synapse_read_with_callees() {
4116 let tmp = tempfile::tempdir().unwrap();
4117 let src = write_source_file(tmp.path(), "Svc.java",
4118 "public class Svc {\n public void main() {\n helper();\n }\n public void helper() {\n int x = 0;\n }\n}");
4119 write_graph_with_abs_source(tmp.path(), "global-graph.json", src.to_str().unwrap());
4120 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4121 let mut args = Map::new();
4122 args.insert("class_name".into(), serde_json::json!("MyClass"));
4123 args.insert("method_name".into(), serde_json::json!("main"));
4124 let result = server.tool_codesynapse_read_with_callees(&args).unwrap();
4125 assert!(result.contains("main"));
4126 assert!(result.contains("helper"));
4127 }
4128
4129 #[test]
4130 fn test_synapse_find_callers_no_graph_edges() {
4131 let tmp = tempfile::tempdir().unwrap();
4132 write_test_graph(
4133 tmp.path(),
4134 "global-graph.json",
4135 &[("a", "Foo", "code")],
4136 &[],
4137 );
4138 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4139 let mut args = Map::new();
4140 args.insert("class_name".into(), serde_json::json!("Foo"));
4141 let result = server.tool_codesynapse_find_callers(&args).unwrap();
4142 assert!(!result.is_empty());
4143 }
4144
4145 #[test]
4146 fn test_synapse_find_callers_via_graph_edges() {
4147 let tmp = tempfile::tempdir().unwrap();
4148 let json = serde_json::json!({
4149 "nodes": [
4150 {"id": "a", "label": "FooService", "file_type": "code", "source_file": "Foo.java"},
4151 {"id": "b", "label": "BarController", "file_type": "code", "source_file": "Bar.java"}
4152 ],
4153 "edges": [
4154 {"source": "b", "target": "a", "relation": "calls", "confidence": "EXTRACTED"}
4155 ]
4156 });
4157 std::fs::write(
4158 tmp.path().join("global-graph.json"),
4159 serde_json::to_string(&json).unwrap(),
4160 )
4161 .unwrap();
4162 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4163 let mut args = Map::new();
4164 args.insert("class_name".into(), serde_json::json!("FooService"));
4165 let result = server.tool_codesynapse_find_callers(&args).unwrap();
4166 assert!(result.contains("BarController") || result.contains("graph call edges"));
4167 }
4168
4169 #[test]
4170 fn test_synapse_find_usages_no_roots() {
4171 let tmp = tempfile::tempdir().unwrap();
4172 write_test_graph(
4173 tmp.path(),
4174 "global-graph.json",
4175 &[("a", "Foo", "code")],
4176 &[],
4177 );
4178 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4179 let mut args = Map::new();
4180 args.insert("class_name".into(), serde_json::json!("Foo"));
4181 let result = server.tool_codesynapse_find_usages(&args).unwrap();
4182 assert!(!result.is_empty());
4183 }
4184
4185 #[test]
4186 fn test_graph_build_no_graph() {
4187 let tmp = tempfile::tempdir().unwrap();
4188 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4189 let result = server.tool_graph_build(&Map::new()).unwrap();
4190 assert!(result.contains("not found") || result.contains("global-graph"));
4191 }
4192
4193 #[test]
4194 fn test_graph_build_with_graph() {
4195 let tmp = tempfile::tempdir().unwrap();
4196 write_test_graph(
4197 tmp.path(),
4198 "global-graph.json",
4199 &[("a", "Foo", "code")],
4200 &[],
4201 );
4202 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4203 let result = server.tool_graph_build(&Map::new()).unwrap();
4204 assert!(result.contains("cleared") || result.contains("reload"));
4205 }
4206
4207 #[test]
4208 fn test_synapse_stats_no_data() {
4209 let tmp = tempfile::tempdir().unwrap();
4210 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4211 let result = server.tool_codesynapse_stats(&Map::new()).unwrap();
4212 assert!(result.contains("No usage data"));
4213 }
4214
4215 #[test]
4216 fn test_synapse_stats_with_data() {
4217 let tmp = tempfile::tempdir().unwrap();
4218 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4219 server.log_tool_call("codesynapse_outline", 100, 5000);
4221 server.log_tool_call("codesynapse_read_method", 200, 8000);
4222 let result = server.tool_codesynapse_stats(&Map::new()).unwrap();
4223 assert!(result.contains("codesynapse"));
4224 assert!(result.contains("calls"));
4225 }
4226
4227 #[test]
4228 fn test_tool_blast_radius_scored_ranks_high_in_degree_first() {
4229 let tmp = tempfile::tempdir().unwrap();
4230 write_test_graph(
4232 tmp.path(),
4233 "global-graph.json",
4234 &[
4235 ("a", "Alpha", "code"),
4236 ("b", "Beta", "code"),
4237 ("c", "C1", "code"),
4238 ("d", "C2", "code"),
4239 ("e", "C3", "code"),
4240 ("f", "C4", "code"),
4241 ("g", "C5", "code"),
4242 ("h", "C6", "code"),
4243 ("i", "C7", "code"),
4244 ("j", "C8", "code"),
4245 ("k", "C9", "code"),
4246 ("l", "C10", "code"),
4247 ],
4248 &[
4249 ("a", "b", "calls"),
4250 ("c", "b", "calls"),
4251 ("d", "b", "calls"),
4252 ("e", "b", "calls"),
4253 ("f", "b", "calls"),
4254 ("g", "b", "calls"),
4255 ("h", "b", "calls"),
4256 ("i", "b", "calls"),
4257 ("j", "b", "calls"),
4258 ("k", "b", "calls"),
4259 ("l", "b", "calls"),
4260 ],
4261 );
4262 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4263 let mut args = Map::new();
4264 args.insert("class_name".into(), serde_json::json!("Alpha"));
4265 args.insert("depth".into(), serde_json::json!(2));
4266 let result = server.tool_graph_blast_radius_scored(&args).unwrap();
4267 assert!(result.contains("Beta"));
4268 assert!(result.contains("HIGH") || result.contains("MEDIUM"));
4269 }
4270
4271 #[test]
4272 fn test_tool_blast_radius_scored_no_node() {
4273 let tmp = tempfile::tempdir().unwrap();
4274 write_test_graph(
4275 tmp.path(),
4276 "global-graph.json",
4277 &[("a", "Alpha", "code")],
4278 &[],
4279 );
4280 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4281 let mut args = Map::new();
4282 args.insert("class_name".into(), serde_json::json!("ZZZGhost"));
4283 let result = server.tool_graph_blast_radius_scored(&args);
4284 assert!(result.is_err());
4285 }
4286
4287 #[test]
4288 fn test_tool_blast_radius_multi_union() {
4289 let tmp = tempfile::tempdir().unwrap();
4290 write_test_graph(
4291 tmp.path(),
4292 "global-graph.json",
4293 &[
4294 ("a", "Alpha", "code"),
4295 ("b", "Beta", "code"),
4296 ("c", "Gamma", "code"),
4297 ("d", "Delta", "code"),
4298 ],
4299 &[("a", "c", "calls"), ("b", "d", "calls")],
4300 );
4301 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4302 let mut args = Map::new();
4303 args.insert("class_names".into(), serde_json::json!(["Alpha", "Beta"]));
4304 let result = server.tool_graph_blast_radius_multi(&args).unwrap();
4305 assert!(result.contains("Alpha") || result.contains("a"));
4306 assert!(result.contains("Beta") || result.contains("b"));
4307 assert!(result.contains("Gamma") || result.contains("Delta") || result.contains("Total"));
4308 }
4309
4310 #[test]
4311 fn test_tool_blast_radius_multi_not_found() {
4312 let tmp = tempfile::tempdir().unwrap();
4313 write_test_graph(
4314 tmp.path(),
4315 "global-graph.json",
4316 &[("a", "Alpha", "code")],
4317 &[],
4318 );
4319 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4320 let mut args = Map::new();
4321 args.insert(
4322 "class_names".into(),
4323 serde_json::json!(["Alpha", "ZZZGhost"]),
4324 );
4325 let result = server.tool_graph_blast_radius_multi(&args).unwrap();
4326 assert!(result.contains("ZZZGhost"));
4327 assert!(result.contains("Not found"));
4328 }
4329
4330 #[test]
4331 fn test_tool_graph_query_semantic_no_semantic_edges() {
4332 let tmp = tempfile::tempdir().unwrap();
4333 write_test_graph(
4334 tmp.path(),
4335 "global-graph.json",
4336 &[("a", "UserService", "code"), ("b", "AuthService", "code")],
4337 &[("a", "b", "calls")],
4338 );
4339 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4340 let mut args = Map::new();
4341 args.insert("query".into(), serde_json::json!("user"));
4342 let result = server.tool_graph_query_semantic(&args).unwrap();
4343 assert!(result.contains("--llm") || result.contains("semantic"));
4344 }
4345
4346 #[test]
4347 fn test_find_nodes_by_label_exact_first() {
4348 let tmp = tempfile::tempdir().unwrap();
4349 let json = serde_json::json!({
4350 "nodes": [
4351 {"id": "a", "label": "FooService", "file_type": "code", "source_file": ""},
4352 {"id": "b", "label": "Foo", "file_type": "code", "source_file": ""}
4353 ],
4354 "edges": []
4355 });
4356 std::fs::write(
4357 tmp.path().join("global-graph.json"),
4358 serde_json::to_string(&json).unwrap(),
4359 )
4360 .unwrap();
4361 let g = load_graph(&tmp.path().join("global-graph.json")).unwrap();
4362 let results = find_nodes_by_label(&g, "Foo");
4363 assert!(!results.is_empty());
4364 assert_eq!(results[0].1.label, "Foo", "exact match should be first");
4365 }
4366
4367 #[test]
4368 fn test_outline_rust() {
4369 let src = r#"
4370pub struct Runtime { handle: Handle }
4371pub trait Scheduler: Send { fn schedule(&self); }
4372impl Runtime {
4373 pub fn new() -> Self { todo!() }
4374 pub async fn block_on<F>(&self, f: F) {}
4375}
4376impl Scheduler for Runtime {
4377 fn schedule(&self) {}
4378}
4379async fn spawn_local<F>(f: F) {}
4380fn park() {}
4381"#;
4382 let items = outline_rust(src);
4383 let kinds: Vec<(&str, &str)> = items
4384 .iter()
4385 .map(|i| (i.kind.as_str(), i.name.as_str()))
4386 .collect();
4387 assert!(kinds.contains(&("class", "Runtime")), "struct→class");
4388 assert!(
4389 kinds.contains(&("interface", "Scheduler")),
4390 "trait→interface"
4391 );
4392 assert!(
4393 kinds.iter().any(|(k, n)| *k == "method" && *n == "new"),
4394 "fn new"
4395 );
4396 assert!(
4397 kinds
4398 .iter()
4399 .any(|(k, n)| *k == "method" && *n == "block_on"),
4400 "async fn"
4401 );
4402 assert!(
4403 kinds
4404 .iter()
4405 .any(|(k, n)| *k == "method" && *n == "spawn_local"),
4406 "top-level async fn"
4407 );
4408 assert!(
4409 kinds.iter().any(|(k, n)| *k == "method" && *n == "park"),
4410 "top-level fn"
4411 );
4412 assert!(
4414 kinds
4415 .iter()
4416 .filter(|(k, n)| *k == "class" && *n == "Runtime")
4417 .count()
4418 >= 2,
4419 "impl+struct both captured"
4420 );
4421 }
4422
4423 #[test]
4424 fn test_outline_go() {
4425 let src = r#"
4426type Server struct { port int }
4427type Handler interface { ServeHTTP() }
4428func NewServer() *Server { return nil }
4429func (s *Server) Start() {}
4430"#;
4431 let items = outline_go(src);
4432 let kinds: Vec<(&str, &str)> = items
4433 .iter()
4434 .map(|i| (i.kind.as_str(), i.name.as_str()))
4435 .collect();
4436 assert!(kinds.contains(&("class", "Server")));
4437 assert!(kinds.contains(&("interface", "Handler")));
4438 assert!(kinds
4439 .iter()
4440 .any(|(k, n)| *k == "method" && *n == "NewServer"));
4441 assert!(kinds.iter().any(|(k, n)| *k == "method" && *n == "Start"));
4442 }
4443
4444 #[test]
4445 fn test_outline_ruby() {
4446 let src = r#"
4447class User
4448 def initialize(name); end
4449 def self.find(id); end
4450end
4451module Auth
4452 def authenticate; end
4453end
4454"#;
4455 let items = outline_ruby(src);
4456 let kinds: Vec<(&str, &str)> = items
4457 .iter()
4458 .map(|i| (i.kind.as_str(), i.name.as_str()))
4459 .collect();
4460 assert!(kinds.contains(&("class", "User")));
4461 assert!(kinds.contains(&("class", "Auth")));
4462 assert!(kinds
4463 .iter()
4464 .any(|(k, n)| *k == "method" && *n == "initialize"));
4465 assert!(kinds.iter().any(|(k, n)| *k == "method" && *n == "find"));
4466 }
4467
4468 #[test]
4469 fn test_outline_elixir() {
4470 let src = r#"
4471defmodule MyApp.Router do
4472 def call(conn, opts), do: conn
4473 defp handle(conn), do: conn
4474end
4475"#;
4476 let items = outline_elixir(src);
4477 let kinds: Vec<(&str, &str)> = items
4478 .iter()
4479 .map(|i| (i.kind.as_str(), i.name.as_str()))
4480 .collect();
4481 assert!(kinds
4482 .iter()
4483 .any(|(k, n)| *k == "class" && n.contains("Router")));
4484 assert!(kinds.iter().any(|(k, n)| *k == "method" && *n == "call"));
4485 assert!(kinds.iter().any(|(k, n)| *k == "method" && *n == "handle"));
4486 }
4487
4488 #[test]
4491 fn test_system_prompt_context_is_primary() {
4492 assert!(
4493 CODESYNAPSE_INSTRUCTIONS.contains("ALWAYS call codesynapse_context FIRST"),
4494 "system prompt must designate codesynapse_context as primary tool"
4495 );
4496 }
4497
4498 #[test]
4499 fn test_system_prompt_resolve_is_step2() {
4500 assert!(
4501 CODESYNAPSE_INSTRUCTIONS.contains("codesynapse_resolve"),
4502 "system prompt must reference resolve as step 2"
4503 );
4504 }
4505
4506 #[test]
4507 fn test_system_prompt_resolve_trigger() {
4508 assert!(
4509 CODESYNAPSE_INSTRUCTIONS.contains("No exact match"),
4510 "system prompt must tell LLM to use resolve when context returns no exact match"
4511 );
4512 }
4513
4514 #[test]
4515 fn test_system_prompt_trust_graph_line_kept() {
4516 assert!(
4517 CODESYNAPSE_INSTRUCTIONS.contains("Do NOT grep/find to verify graph results"),
4518 "trust-the-graph line must be kept"
4519 );
4520 }
4521
4522 #[test]
4525 fn test_sibling_hint_detects_methods_in_same_class() {
4526 let source = "public class ProfileDatafetcher {\n\
4527 public User getUserProfile(DataFetchingEnvironment env) {\n\
4528 return env.getLocalContext();\n\
4529 }\n\
4530 private User queryProfile(DataFetchingEnvironment env) {\n\
4531 return SecurityUtil.getCurrentUser();\n\
4532 }\n\
4533 public void otherMethod() {}\n\
4534 }\n";
4535 let items = outline_java(source);
4536 let class_items: Vec<_> = items.iter().filter(|i| i.kind == "class").collect();
4537 let method_items: Vec<_> = items.iter().filter(|i| i.kind == "method").collect();
4538 assert!(!class_items.is_empty(), "expected class item");
4539 assert!(method_items.len() >= 2, "expected at least 2 method items");
4540
4541 let method = method_items
4543 .iter()
4544 .find(|i| i.name == "getUserProfile")
4545 .unwrap();
4546 let parent = items
4547 .iter()
4548 .filter(|i| i.kind == "class" && i.line <= method.line)
4549 .max_by_key(|i| i.line)
4550 .unwrap();
4551 assert_eq!(parent.name, "ProfileDatafetcher");
4552 let siblings = items
4553 .iter()
4554 .filter(|i| {
4555 matches!(i.kind.as_str(), "method" | "function")
4556 && i.line > parent.line
4557 && i.name != method.name
4558 })
4559 .count();
4560 assert!(
4561 siblings >= 2,
4562 "expected at least 2 siblings, got {}",
4563 siblings
4564 );
4565 }
4566
4567 #[test]
4570 fn test_resolve_top_k_default_is_3() {
4571 let default = McpServer::int_arg(&Map::new(), "top_k", 3);
4572 assert_eq!(default, 3, "top_k default must be 3");
4573 }
4574
4575 #[test]
4578 fn test_graph_cache_returns_same_arc() {
4579 use std::sync::Arc;
4580 let tmp = tempfile::tempdir().unwrap();
4581 write_test_graph(
4582 tmp.path(),
4583 "global-graph.json",
4584 &[("a", "Alpha", "code")],
4585 &[],
4586 );
4587 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4588 let g1 = server.load_module_serve_graph("merged").unwrap();
4589 let g2 = server.load_module_serve_graph("merged").unwrap();
4590 assert!(
4591 Arc::ptr_eq(&g1, &g2),
4592 "second load must return the cached Arc"
4593 );
4594 }
4595
4596 #[test]
4597 fn test_graph_cache_cleared_by_build() {
4598 use std::sync::Arc;
4599 let tmp = tempfile::tempdir().unwrap();
4600 write_test_graph(
4601 tmp.path(),
4602 "global-graph.json",
4603 &[("a", "Alpha", "code")],
4604 &[],
4605 );
4606 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4607 let g1 = server.load_module_serve_graph("merged").unwrap();
4608 server.tool_graph_build(&Map::new()).unwrap();
4609 let g2 = server.load_module_serve_graph("merged").unwrap();
4610 assert!(
4611 !Arc::ptr_eq(&g1, &g2),
4612 "build must clear cache, forcing a fresh load"
4613 );
4614 }
4615
4616 #[test]
4619 fn test_source_cache_populated_after_resolve() {
4620 let tmp = tempfile::tempdir().unwrap();
4621 let src = write_source_file(
4622 tmp.path(),
4623 "Foo.java",
4624 "public class Foo {\n public void run() {}\n}",
4625 );
4626 write_graph_with_abs_source(tmp.path(), "global-graph.json", src.to_str().unwrap());
4627 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4628 let mut args = Map::new();
4629 args.insert("query".into(), serde_json::json!("Foo"));
4630 server.tool_codesynapse_resolve(&args).unwrap();
4631 assert!(
4632 !server.source_cache.lock().unwrap().is_empty(),
4633 "source cache must be populated after resolve reads a file"
4634 );
4635 }
4636
4637 #[test]
4638 fn test_source_cache_capped_at_1000() {
4639 let tmp = tempfile::tempdir().unwrap();
4640 let real = write_source_file(tmp.path(), "Real.java", "public class Real {}");
4641 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4642 {
4643 let mut cache = server.source_cache.lock().unwrap();
4644 for i in 0..1000usize {
4645 cache.insert(
4646 PathBuf::from(format!("/fake/{}.java", i)),
4647 (SystemTime::UNIX_EPOCH, String::new(), Vec::new()),
4648 );
4649 }
4650 }
4651 assert_eq!(server.source_cache.lock().unwrap().len(), 1000);
4652 server.get_source_cached(&real);
4653 let len = server.source_cache.lock().unwrap().len();
4654 assert!(len < 1000, "cache must be evicted when full (got {})", len);
4655 }
4656
4657 #[test]
4658 fn test_stale_check_returns_false_when_no_modules_dir() {
4659 let tmp = tempfile::tempdir().unwrap();
4660 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4661 assert!(!server.is_graph_stale(), "no modules dir → not stale");
4662 }
4663
4664 #[test]
4665 fn test_stale_check_returns_false_when_modules_up_to_date() {
4666 let tmp = tempfile::tempdir().unwrap();
4667 let global_dir = tmp.path().to_path_buf();
4668 let modules_dir = global_dir.join("modules").join("mymod");
4669 std::fs::create_dir_all(&modules_dir).unwrap();
4670 std::fs::write(modules_dir.join("graph.json"), b"{}").unwrap();
4672 std::thread::sleep(std::time::Duration::from_millis(10));
4673 std::fs::write(global_dir.join("global-graph.json"), b"{}").unwrap();
4674 let server = make_server_with_global(setup_store(), global_dir);
4675 assert!(!server.is_graph_stale());
4676 }
4677
4678 #[test]
4679 fn test_stale_check_returns_true_when_module_newer_than_global() {
4680 let tmp = tempfile::tempdir().unwrap();
4681 let global_dir = tmp.path().to_path_buf();
4682 let modules_dir = global_dir.join("modules").join("mymod");
4683 std::fs::create_dir_all(&modules_dir).unwrap();
4684 std::fs::write(global_dir.join("global-graph.json"), b"{}").unwrap();
4686 std::thread::sleep(std::time::Duration::from_millis(10));
4687 std::fs::write(modules_dir.join("graph.json"), b"{}").unwrap();
4688 let server = make_server_with_global(setup_store(), global_dir);
4689 assert!(server.is_graph_stale());
4690 }
4691
4692 #[test]
4693 fn test_check_stale_once_caches_result() {
4694 let tmp = tempfile::tempdir().unwrap();
4695 let server = make_server_with_global(setup_store(), tmp.path().to_path_buf());
4696 let first = server.check_stale_once();
4697 *server.stale_check.lock().unwrap() = Some(true);
4699 assert!(
4700 server.check_stale_once(),
4701 "should return cached true, not re-check"
4702 );
4703 let _ = first;
4704 }
4705
4706 fn make_server_with_module(
4709 module_name: &str,
4710 graph_json: serde_json::Value,
4711 ) -> (McpServer, tempfile::TempDir) {
4712 let tmpdir = tempfile::TempDir::new().unwrap();
4713 let graph_path = tmpdir.path().join(format!("{}.json", module_name));
4714 std::fs::write(&graph_path, graph_json.to_string()).unwrap();
4715 let manifest = serde_json::json!({
4716 "version": 1,
4717 "repos": {
4718 module_name: {
4719 "added_at": "2024-01-01",
4720 "source_path": graph_path.to_str().unwrap(),
4721 "node_count": 1,
4722 "edge_count": 0,
4723 "source_hash": ""
4724 }
4725 }
4726 });
4727 std::fs::write(
4728 tmpdir.path().join("global-manifest.json"),
4729 manifest.to_string(),
4730 )
4731 .unwrap();
4732 let global_dir = tmpdir.path().to_path_buf();
4733 let server = McpServer {
4734 backend: Mutex::new(StoreBackend::Memory(MemoryGraphStore::new())),
4735 graph_path: None,
4736 last_mtime: Mutex::new(None),
4737 telemetry: Arc::new(codesynapse_core::telemetry::Telemetry::new(
4738 global_dir.clone(),
4739 )),
4740 global_dir,
4741 embedder: None,
4742 node_embeddings: HashMap::new(),
4743 graph_cache: Mutex::new(HashMap::new()),
4744 source_cache: Mutex::new(HashMap::new()),
4745 stale_check: Mutex::new(None),
4746 };
4747 (server, tmpdir)
4748 }
4749
4750 #[test]
4751 fn test_context_skips_node_with_missing_source() {
4752 let src_dir = tempfile::TempDir::new().unwrap();
4754 let src_file = src_dir.path().join("real.py");
4755 std::fs::write(&src_file, "def authenticate():\n pass\n").unwrap();
4756
4757 let graph = serde_json::json!({
4758 "nodes": [
4759 {
4760 "id": "real_node",
4761 "label": "authenticate",
4762 "source_file": src_file.to_str().unwrap(),
4763 "file_type": "code"
4764 },
4765 {
4766 "id": "dead_node",
4767 "label": "authenticate",
4768 "source_file": "/nonexistent/dead.py",
4769 "file_type": "code"
4770 }
4771 ],
4772 "edges": []
4773 });
4774
4775 let (server, _dir) = make_server_with_module("fix1-skip", graph);
4776 let mut args = Map::new();
4777 args.insert("query".into(), "authenticate".into());
4778 args.insert("graph".into(), "fix1-skip".into());
4779 let result = server.tool_codesynapse_context(&args).unwrap();
4780
4781 assert!(
4782 !result.contains("dead.py"),
4783 "node with missing source should not appear; output: {}",
4784 &result[..result.len().min(300)]
4785 );
4786 assert!(
4787 result.contains("Exact match found"),
4788 "readable node should still produce exact match; output: {}",
4789 &result[..result.len().min(300)]
4790 );
4791 }
4792
4793 #[test]
4794 fn test_context_fallback_when_all_sources_missing() {
4795 let graph = serde_json::json!({
4796 "nodes": [
4797 {
4798 "id": "dead_node",
4799 "label": "authenticate",
4800 "source_file": "/nonexistent/dead.py",
4801 "file_type": "code"
4802 }
4803 ],
4804 "edges": []
4805 });
4806
4807 let (server, _dir) = make_server_with_module("fix1-fallback", graph);
4808 let mut args = Map::new();
4809 args.insert("query".into(), "authenticate".into());
4810 args.insert("graph".into(), "fix1-fallback".into());
4811 let result = server.tool_codesynapse_context(&args).unwrap();
4812
4813 assert!(
4814 result.starts_with("[No exact match"),
4815 "when all exact-match sources are missing, should show semantic fallback; got: {}",
4816 &result[..result.len().min(300)]
4817 );
4818 }
4819}